1 Commits

Author SHA1 Message Date
Danny Kim 50328caac5 Drop Deno from the agents and spawn child processes directly
Check / check (pull_request) Successful in 2m26s
Agents / agent (pull_request) Has been skipped
2026-10-16 23:26:28 +09:00
49 changed files with 1440 additions and 923 deletions
+36
View File
@@ -0,0 +1,36 @@
---
description: Containerfiles build the frontend in a node:24-alpine stage with npm ci and vp build; CI uses setup-vp and runs vp check, vp build, vp test (plus lit-check)
---
## Containerfiles
- The frontend build stage is `docker.io/library/node:24-alpine`, replacing
`denoland/deno:alpine`. Vite+ publishes `linux-x64-musl` binaries, so Alpine
works. (`ghcr.io/voidzero-dev/vite-plus` is a glibc alternative that would
need a pinned digest; not used.)
- The stage copies `package.json` and `package-lock.json` first, runs
`npm ci`, then copies the sources and configs and runs `npx vp build`.
- The runtime stage copies the built `js/` and `css/` directories (not single
files) from the node stage.
- No Deno binary, `deno install`, or `deno task` remains in any stage.
```dockerfile
FROM docker.io/library/node:24-alpine AS frontend
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npx vp build
```
## CI
- GitHub repos (temeddix): `.github/workflows`. Capsulizers repos (Gitea):
`.gitea/workflows`. Don't move a repo's CI between hosts.
- Install with `voidzero-dev/setup-vp`, then run `vp check`, `vp build`, and
`vp test`; Lit repos also run `npm run lit-check`
([[lit-analyzer-ts6]]).
- Repos that ran CI on a self-hosted runner keep it.
- Until `@cunarist/eslint-plugin-*` are on npm, `npm ci` fails in CI. That is
expected and stated in the PR; run `npm install` once they are published
and commit the refreshed lockfile.
+20
View File
@@ -0,0 +1,20 @@
---
description: Code that must ship as standalone JS (memona framed_find.js, brain stream-recovery.js) is written as .ts, compiled at build time, and its output gitignored
---
Some apps ship plain JavaScript that runs outside the main bundle: memona's
`framed_find.js` and brain's `stream-recovery.js`. Under the TS-only rule
([[ts-only-files-and-imports]]) they are handled like this (2026-10-16):
- The source is a `.ts` file, linted and type-checked like everything else.
- The `.js` is produced at build time by the existing build (a Vite input or
a build step in `vite.config.ts` or `scripts/`), never committed.
- The output path is gitignored and, if it lands in a linted folder, listed
in `lint.ignorePatterns`; never in `overrides`, never with disable comments.
- Whatever loads the file (Rust `include_str!`, a container `COPY`, an HTML
`<script>`) keeps the same path and name as before, so behavior is
unchanged. Builds that need the file must run the compile first; check
that the Containerfile and CI order the steps that way.
Don't commit a hand-maintained `.js` copy "for convenience", and don't turn
it into `.mjs` or `.cjs`.
@@ -0,0 +1,41 @@
---
description: Apps use the npm lint plugins' configs, port their old deno-lint-plugin.ts rules into scripts/project-lint.ts, never override source files, and refactor instead of raising limits
---
## Plugins
Apps consume the published plugins as npm devDependencies, through `jsPlugins`
and the configs equivalent to what their Deno setup enabled:
- `@cunarist/eslint-plugin-explicit-types`: `concrete`, `naming`, `absence`
- `@cunarist/eslint-plugin-import-check`: `import-check`
- `@cunarist/eslint-plugin-lit`: `core`, `strict`, `dom-ref`,
`reactive-controller`, `naming`
## Repo-local rules
The local `scripts/deno-lint-plugin.ts` that home, eximium, stockvibe, brain,
memona, and memona-home carried is ported into `scripts/project-lint.ts`
([[project-lint-plugin]]) under the `project` namespace, same rule names and
behavior. Depending on the repo: `no-self-sync`, `no-sync-in-render`,
`app-module-conventions`, `always-wildcard-export`, `no-class-extends`,
`no-lit-nothing`, `no-title-attribute`, `prefer-wa-components`,
`no-satisfies`, `no-unknown`, `restricted-types`, `no-line-lint-ignore`,
`no-app-disposables-in-component`, `no-crepe-react-ui`. `@hugoalh` rules map to
Oxlint built-ins in `configs.project`.
memona's `deno-lint-plugin.ts` and its `coding-standards` memory are the house
style the explicit-types rules came from; they win any conflict.
## No overrides, refactor instead
Apps use **no `overrides` for source files at all** (the rule-registry
exception exists only in plugin repos). Oxlint's default limits stay, and
violations are refactored without behavior change in the same PR: barrels for
`max-dependencies`, splits for `max-lines`/`max-lines-per-function`/
`max-classes-per-file`, an options object for `max-params`. Long Lit
`render()` templates are split into smaller template functions or
components. See [[lint-overrides-policy]].
If a hand-written exception still looks truly necessary, ask the user first;
when they're away, leave it failing or refactor, and log the question.
@@ -12,16 +12,12 @@ Node + Vite+ migration (PR #20, `node-migration`, 2026-10-15/16):
consumer (`uses: .../commons/bot-agents@<branch>`) on a throwaway PR.
- **Profiles are JSON.** `profiles.yml` became `profiles.json` with the same
content (encrypted `v1:` values unchanged), read with `JSON.parse`. That
removed the only runtime dependency (`@std/yaml`); keep it free of runtime
dependencies (`@eslint/core` and the other devDependencies serve only the
tooling).
removed the only runtime dependency (`@std/yaml`); keep it dependency-free.
- **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. `engines.node`
is `>=24.2`, which the user accepted for this repo
([[bot-agents-runtime-no-build]]).
- **No `@cunarist/eslint-plugin-*`.** The Deno setup enabled no plugin, so
this repo depends on none and CI passes without them being published.
Planned (2026-10-17): once the plugins are on npm, add
`@cunarist/eslint-plugin-modules` here for its import checks.
- **Stricter input checks were accepted** in the migration: Gitea `/user` and
comment responses, the runtime manifest, and the outcome's profile type are
validated; some type errors became `TypeError` with the same messages.
+43
View File
@@ -0,0 +1,43 @@
---
description: Frontends build with vp build in Vite app mode, keeping the exact output paths the Rust backend and HTML expect; emptyOutDir false when outDir holds committed files
---
`deno bundle` was replaced by `vp build` in **Vite app mode** (not
`build.lib`). Behavior must not change, so the output keeps the file names and
locations the backend serves and the HTML references.
Typical shape for the Rust-backed frontends (home, eximium, stockvibe,
memona-home), where `frontend/` builds into `backend/static`:
```ts
build: {
outDir: "../backend/static",
emptyOutDir: false, // the folder holds committed files (index.html, ...)
rollupOptions: {
input: { bundle: "src/index.ts" },
output: { entryFileNames: "js/[name].js" }, // -> js/bundle.js
},
},
```
- **Fixed names, no hashes:** set `entryFileNames` (and `assetFileNames`
where assets are emitted) explicitly. Vite's hashed defaults would 404,
since Rust and the templates request `js/bundle.js` and `css/theme.css`.
- **`emptyOutDir: false` whenever `outDir` is outside the Vite root or holds
committed files.** Never let it default to true there: it would wipe them.
- Output stays gitignored, so `.gitignore`, `.containerignore`, and the Rust
side need no edits; home's startup check for `js/bundle.js` and
`css/theme.css` keeps passing.
- **CSS is never imported from TS** (no `import "./theme.css"`); the HTML
links the stylesheet. How the CSS file is produced is per repo; read the
repo's `vite.config.ts` before changing it.
- Copy whole `js/` and `css/` directories in Containerfiles, not single files,
so code-split chunks ship too ([[app-containers-and-ci]]).
- Where a repo serves an `index.html` entry (brain's dashboard), use plain app
mode with `index.html` as input, `base` set to the mount path, and `outDir:
"dist"` with the default `emptyOutDir`.
- `vp dev` must work too, and the decorators plugin must cover build, dev, and
test ([[decorators-standard-accessor]]).
Check the repo's own migration PR for its final values before editing; these
are the conventions it followed, not a config to paste.
+12 -13
View File
@@ -1,21 +1,20 @@
---
description: bot-agents lint has prefer-readonly-parameter-types and the size/complexity rules off (Deno had no limits) and no overrides; nothing else
description: bot-agents lint has prefer-readonly-parameter-types off (Node/web types can't satisfy it) and one scripts/** override for no-unsafe-enum-comparison; nothing else
---
The repo follows [[lint-config-shape]] with one recorded exception, present
when the migration PR was approved:
The repo follows [[lint-config-shape]] with two recorded exceptions, both
present when the migration PR was approved:
- `typescript/prefer-readonly-parameter-types` is `"off"` in
`scripts/project-lint.ts`, as in the explicit-types template, because
Node/web types (`Response`, `Stats`, `RequestInit`) can never satisfy it.
- `vite.config.ts` has no `overrides`. The `scripts/**` override for
`typescript/no-unsafe-enum-comparison` was removed (2026-10-16):
`scripts/project-lint.ts` compares node types with `AST_NODE_TYPES` members
from `@typescript-eslint/types` instead of string literals
([[lint-overrides-policy]]).
- `vite.config.ts` has one `overrides` entry, for `scripts/**` only:
`typescript/no-unsafe-enum-comparison` off, because the lint plugin compares
AST node `type`s, which the AST types declare as an enum. This is the
tooling exception [[lint-overrides-policy]] allows.
Size and complexity limits match Deno, which had none here, so every Oxlint
limit rule is `"off"` ([[lint-config-shape]]). `run.ts` and `post.ts` keep
main's single-file layout (2026-10-17 revert of the size splits). Sequential
awaits in loops became recursion or a `reduce` chain (`no-await-in-loop`) to
stay sequential. Add no other opt-out without the user.
Oxlint's default size limits apply to `src/`; the migration split `run.ts`
into `run-context.ts`, `agents.ts`, and `agent-{claude,codex,opencode}.ts`,
and `post.ts` into `post.ts` + `post-artifacts.ts`, rather than raising them.
Sequential awaits in loops became recursion or a `reduce` chain to stay
sequential. Add no other opt-out without the user.
@@ -17,14 +17,15 @@ properties, no decorators) and relative `.ts` imports.
phase. It copies exactly `src/*.ts`, `package.json` (`"type": "module"`
makes Node load the sources as ES modules), `profiles.json`, and
`prompt.md`. **A new runtime file or subdirectory must be added there**, or
it is missing at run time. Runtime code in `src/` is flat; a subfolder would
not be copied. The only subfolder, `src/testing/`, is test-only.
it is missing at run time. `src/` is flat; a subfolder would not be copied.
- `tests/entrypoints.test.ts` runs each phase under real Node with a cleared
environment and expects its first "X is not set" error, which proves the
whole import graph loads without a build. Keep it in sync with the phases.
- **None of the app build conventions apply here:** no `vp build`, no
frontend or Rust/Tauri output paths, no Containerfile, no decorators
plugin, no lit-check, no generated JS. Don't add any of them.
plugin, no lit-check, no generated JS ([[app-vite-build-output]],
[[app-containers-and-ci]], [[app-generated-js-as-ts]] describe the other
apps). Don't add any of them.
- **CI** (`.gitea/workflows/check.yml`) runs on the self-hosted runner's
default image as root: `npm ci`, `npx vp check`, `npx vp test`. It does not
use `setup-vp`. YAML stays declarative ([[yaml-stays-declarative]]).
+6 -9
View File
@@ -1,25 +1,22 @@
---
description: Isolation and credential tests need root and use test.skipIf(!IS_ROOT); locally 5 skip, CI runs as root so all 66 run; verify isolation changes in node:24-bookworm as root
description: Isolation and credential tests need root and use test.skipIf(!IS_ROOT); locally 5 skip, CI runs as root so all 61 run; verify isolation changes in node:24-bookworm as root
---
Some tests exercise real UID isolation, process cleanup, and `prepare.ts`
credential cleanup. They need root and are written as
`test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `#testing`), so they show
`test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `src/testing.ts`), so they show
as skipped instead of silently passing.
- As a normal user `vp test` passes 61 and skips 5. In CI (self-hosted
runner, root) all 66 run.
- As a normal user `vp test` passes 56 and skips 5. In CI (self-hosted
runner, root) all 61 run.
- When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare.ts`,
or `child.ts`, run the suite as root in `node:24-bookworm` (e.g. a
or `command.ts`, run the suite as root in `node:24-bookworm` (e.g. a
container with the repo mounted) before pushing; a green non-root run
proves nothing about those paths.
- The migration also ran an end-to-end smoke as root with fake
`codex`/`claude`/`npx` binaries and a fake Gitea through
`protect-source.sh` -> `prepare.ts` -> `run.ts` -> `post.ts`; repeat that
for changes to the phase flow ([[bot-agents-runtime-no-build]]).
- `src/testing/index.ts` holds the shared test helpers (user IDs, temp dirs,
`runCommand`) behind the `#testing` alias. Every test imports it as
`#testing`. It imports no source module, so `tests/` (which may not use
`../`) never reaches internals. Tests follow
- `src/testing.ts` holds the shared test helpers; tests follow
[[tests-layout-vitest]] (unit tests in `src/*.test.ts`, repo-level ones in
`tests/`).
@@ -0,0 +1,44 @@
---
description: Keep TC39 standard decorators with accessor; Oxc can't lower them, so scripts/decorators.ts (esbuild Vite plugin) does; never legacy decorators
---
Applies to every repo that uses decorators (the Lit frontends).
**Decision (2026-10-16):** keep standard (TC39) decorators with `accessor`,
exactly as the Deno code had them. No legacy decorators.
```ts
@customElement("app-card")
export class AppCard extends LitElement {
@property() accessor name = ""; // keep this form
}
```
**Gotcha, verified 2026-10-16:** Vite 8's Oxc transformer parses standard
decorators but can **not** lower them, so `vp build`, `vp dev`, and `vp test`
emit decorator syntax browsers and Node don't run. Check again on each Vite+
upgrade; when Oxc can lower them, delete the plugin and the `esbuild`
devDependency.
**Fix in use:** `scripts/decorators.ts`, a small Vite plugin backed by
`esbuild` (devDependency), registered in `vite.config.ts` `plugins` so it
covers build, dev server, and Vitest alike:
- `enforce: "pre"`, so it runs before Oxc;
- transforms only `.ts` modules whose source contains `@`;
- `esbuild.transform(code, { loader: "ts", target: "es2022", sourcemap,
sourcefile: id })`, returning `{ code, map }`.
It is tooling under `scripts/`: no tests, and it must pass every lint rule.
**The options as presented to the user:**
| Option | What | Why not |
| ------ | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| A | Legacy decorators: two tsconfig flags (`experimentalDecorators`, ...) and a codemod removing `accessor` | the user ruled out legacy decorators |
| B | Wait for Oxc to lower `accessor` decorators | not available yet; blocks the migration |
| C | No decorators: `static properties = {...}` + `declare` fields | rewrites every component |
| D | A transform step in the Vite build | chosen, with esbuild as the transformer (above) |
Don't add `experimentalDecorators` to `tsconfig.json`, and don't strip
`accessor` from fields.
+45
View File
@@ -0,0 +1,45 @@
---
description: Every repo moved from Deno to Node + Vite+ in Oct 2026 because Cloudflare bought Deno; nothing goes back to Deno
---
On 2026-10-12 the user moved every project from Deno to Node.js + Vite+
(`vp`). Cloudflare bought Deno on 2026-10-09; the runtime gets roughly 12
months of maintenance and Deno Deploy shuts down around April 2027. None of the
repos used Deploy or KV, so the move is about the toolchain, not hosting.
Do not reintroduce `deno.json`, `deno.lock`, `Deno.*` APIs, `jsr:` or `npm:`
specifiers, `mod.ts` entries, or `deno task` anywhere, docs and scripts
included.
## Order the migration followed
1. The custom Deno lint plugins were ported to npm first, as
`@cunarist/eslint-plugin-explicit-types`, `-import-check`, and `-lit`.
`cunarist/eslint-plugin-explicit-types` is the template every other repo
copies (config shape, `scripts/project-lint.ts`, AGENTS.md, notes).
2. Then `commons/bot-agents` (six repos use it as an action), then the apps:
home, eximium, stockvibe, brain, memona, memona-home.
3. deno-practice, interview-practice, web-landing, and vemium were left out
(archivable). Old `cunarist/deno-*` repos are archived one at a time, and
only after their `eslint-plugin-*` replacement is published to npm.
## What "migrated" means
- Same behavior. A migration PR changes tooling, not features.
- Lint coverage is at least what Deno enforced: [[lint-parity-never-shrinks]].
- Only three config files and only `.ts`: [[toolchain-node-vite-plus]],
[[ts-only-files-and-imports]].
- Deno.test suites became Vitest: [[tests-layout-vitest]].
The custom plugins' Deno names map like this:
| Deno | npm |
| ----------------------------------------------- | ---------------------------------------- |
| `jsr:@cunarist/deno-lint-plugin-explicit-types` | `@cunarist/eslint-plugin-explicit-types` |
| `jsr:@cunarist/deno-import-check` (`./lint`) | `@cunarist/eslint-plugin-import-check` |
| `jsr:@cunarist/deno-lint-plugin-lit` | `@cunarist/eslint-plugin-lit` |
| `scripts/deno-lint-plugin.ts` (per app) | `scripts/project-lint.ts` (per app) |
Until those packages are on npm, a repo that depends on them fails `npm ci` in
CI; that is expected and stated in the PR, never worked around by committing
tarballs or `file:` dependencies.
+1
View File
@@ -9,6 +9,7 @@ description: Work on a branch, plain imperative commits without AI trailers, one
- Commit messages are plain imperative sentences ("Port the import-check
rules to an ESLint plugin"). No conventional-commit prefixes, no AI
trailers (`Co-Authored-By: Claude`, "Generated with ..."), no emoji.
- A version bump is its own commit titled `Version X.Y.Z` ([[publishing-ci-only]]).
- When several agents share a branch, each works in its own `git worktree`,
runs `git pull --rebase` before pushing, and retries a rejected push after
rebasing. Remove the worktree and temp branch afterwards.
+43
View File
@@ -0,0 +1,43 @@
---
description: Icons are .ts modules exporting a typed Lit svg template (memona's style), one per file behind an index.ts; never .svg imports as text
---
memona's way of declaring icons is the standard (2026-10-14). An icon is a
`.ts` module that exports a Lit `TemplateResult` holding the inline SVG:
```ts
// src/icons/lock.ts
import { html, type TemplateResult } from "lit";
/** Font Awesome Free's solid `lock`, as its SVG source. */
export const lockIcon: TemplateResult = html`<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 384 512"
>
<path fill="currentColor" d="M128 96l0 64 ..." />
</svg>`;
```
```ts
// usage
import { lockIcon } from "#icons";
render() { return html`<span class="icon">${lockIcon}</span>`; }
```
- One icon per file, camelCase name with an `Icon` suffix, a doc comment
saying what it depicts, exported through `src/icons/index.ts`.
- Use `fill="currentColor"` so CSS controls the colour.
- Keep a third-party licence comment that the SVG source carries (Font
Awesome Free's `<!--! ... -->` notice) inside the template.
**Replaced pattern:** eximium, stockvibe, and memona-home imported Font
Awesome `.svg` files as text in `icons.ts`:
```ts
import lock from "@fortawesome/fontawesome-free/svgs/solid/lock.svg" with { type: "text" }; // no
```
That breaks [[ts-only-files-and-imports]] (non-`.ts` import plus import
attributes) and was converted to memona's style during the frontend
migration. Before writing new icon code in any repo, read how that repo (or
memona) already declares icons and match it.
+24 -78
View File
@@ -1,102 +1,48 @@
---
description: An alias owns its target (folder index.ts = folder and below, any other file = that file); no overlap; outside an area only the exact alias reaches it; ../ is banned in every file; locally, aliases target only direct src/ subfolders
description: Shared code comes through # aliases in package.json imports, each directory's entry is index.ts, barrels keep imports few; never ../ out of a directory
---
## `#` aliases own their targets
## `#` aliases
Shared code is imported through Node subpath imports declared in
`package.json` `imports`. Node, TypeScript, Vite, Vite+'s config bundler, and
Oxlint's plugin loader all resolve them natively, so `.ts` source runs
unbuilt. `vp pack` inlines them, so they never reach package consumers.
One rule for every file, never tied to folder or file names (user rule
2026-10-17; it replaces the earlier "aliases only point at direct `src/`
subfolders" rule as the general rule, and the "modules plugin checks only
aliased folders" scope; this repo keeps the stricter rule as a local
convention, see the last section):
- **An alias may point anywhere** and owns its target. An alias to a
folder's `index.ts` owns that folder and everything below it; an alias to
any other file owns only that file.
- **No overlap.** No file may be owned by 2 aliases: no alias inside another
alias's area, and no 2 aliases with the same target. The modules plugin's
`enforce-layer-order` reports it once at the top of every file 2 aliases
claim. Fix by merging the aliases or moving files apart.
- **Outside an area, only the exact alias reaches it.** A deeper alias
specifier (`#a/one.ts`, `no-barrel-bypass`) and a relative path into the
area (`./a/one.ts`, `no-relative-bypass`; `./b/f.ts` to a file alias,
`prefer-alias-import`, fixed to the alias) are bypasses.
- **Inside an area, relative imports of siblings and subfolders are free.**
- **`../` is banned in every file**, owned or not (`no-parent-import`).
Anything above a file needs an alias.
- **Files no alias owns** need no `index.ts` and no alias, and import `./`
siblings and subfolders freely.
- Unchanged: no `/*` subpath patterns, the key order of `imports` is the
layer order `enforce-layer-order` checks (add a new alias at the right
layer), and entry files are `index.ts` only.
`package.json` `imports`, never by a relative path that climbs out of a
directory:
```jsonc
"imports": {
"#a": "./a/index.ts", // owns a/**
"#f": "./b/f.ts", // owns only b/f.ts
"#a-sub": "./a/sub/index.ts", // no: inside #a
"#a2": "./a/index.ts" // no: same target as #a
"#helpers": "./src/helpers/index.ts",
"#components/*": "./src/components/*"
}
```
```ts
// in main.ts, which no alias owns
import { a } from "#a"; // yes
import { one } from "#a/one.ts"; // no: deeper alias specifier
import { one } from "./a/one.ts"; // no: relative path into #a
import { f } from "#f"; // yes
import { f } from "./b/f.ts"; // no: relative path to a file alias
import { g } from "./b/g.ts"; // yes: unowned
// in a/one.ts, owned by #a
import { two } from "./two.ts"; // yes: sibling
import { deep } from "./sub/deep.ts"; // yes: subfolder of the same area
import { x } from "../x.ts"; // no: no ../ anywhere
import { typeNameTail } from "#helpers"; // yes
import { typeNameTail } from "../helpers/ast.ts"; // no: import/no-relative-parent-imports
```
Node, TypeScript, Vite, Vite+'s config bundler, and Oxlint's plugin loader
all resolve them natively, so `.ts` source runs unbuilt. `vp pack` inlines them,
so they never reach package consumers.
- Deno's trailing-slash mappings (`"#components/"`) don't exist in Node; they
become patterns, `"#components/*": "./src/components/*"`.
- Aliases point at `.ts` files only. There is no `#package` alias
([[ts-only-files-and-imports]]).
- Lint `jsPlugins` entries in `vite.config.ts` stay path strings, even when
the file has an alias.
- In repos linted by import-check, the key order of `imports` is the layer
order `enforce-layer-order` checks; add a new alias at the right layer.
- Keep test-only helpers in their own alias (e.g. `#testing`) so they never
land in a bundle.
## Barrels
## `index.ts` entries and barrels
- An area used from outside exposes its API through its alias target, and
callers import the alias (`#ui`), not files inside it.
- Every directory's entry file is `index.ts`, never `mod.ts` (decided
2026-10-12; import-check's `enforce-mod-file` now requires `index.ts`).
- A directory used from outside exposes its API through its `index.ts`
barrel, and callers import the barrel (`#ui`), not files inside it
(`no-barrel-bypass`).
- Barrels are also the standard fix for `import/max-dependencies`: one barrel
import replaces many direct ones ([[lint-overrides-policy]]).
- `import/no-cycle` is on; a barrel must not create a cycle.
Relative imports are written with the `.ts` extension.
## `scripts/` is flat (user rule, 2026-10-16)
- `scripts/` has no subfolders and no `index.ts`.
- Each tool is one file, `scripts/<name>.ts`, imported directly by file:
`import projectLint from "./scripts/project-lint.ts";` in
`vite.config.ts`. No alias owns `scripts/`, so these `./` imports are free.
- Code in a former `scripts/<tool>/` folder is merged or renamed into flat
`scripts/<name>.ts` files. Non-TS assets (HTML, images, fixtures) move to
where they belong (next to the tool's output, `.agents/`, or a gitignored
scratch dir). A subfolder that is really an app moves under an alias.
## Local convention: aliases target direct `src/` subfolders
This is a convention of this repo only, stricter than the general rule above
(which the published modules plugin checks and which allows more). It is not
a plugin rule.
- Every `package.json` `imports` alias points only at the `index.ts` of a
direct subfolder of `src/`, e.g. `"#helpers": "./src/helpers/index.ts"`.
No alias to a single file, a deeper folder, or anything outside `src/`.
Here the one alias is `"#testing": "./src/testing/index.ts"`.
- Code outside `src/` (`scripts/`, `tests/`, `vite.config.ts`) gets no alias
of its own and imports its own files by `./` relative paths. It reaches
`src/` only through the aliases, since `../` is banned.
- No lint rule checks this; check `imports` by hand when adding an alias.
Relative imports inside one directory (`./sibling.ts`) are fine, with the
`.ts` extension written out.
+10 -11
View File
@@ -1,5 +1,5 @@
---
description: vite.config.ts lint uses Oxlint categories at error with Deno size limits, names no rule itself, and spreads configs from the plugins and scripts/project-lint.ts
description: vite.config.ts lint uses Oxlint categories at error with default limits, names no rule itself, and spreads configs from the plugins and scripts/project-lint.ts
---
The lint config is short and category-driven (user decisions 2026-10-14/15):
@@ -19,15 +19,14 @@ lint: {
- **Categories:** `correctness`, `suspicious`, `pedantic`, `perf` at `error`.
`style` and `restriction` are never turned on wholesale; pick single rules
from them in `configs.project`. Extra rules that categories bring are fine.
- **Size limits match Deno** (2026-10-17, replaces the 2026-10-16 "default
limits stay"): repos behave as they did on Deno. bot-agents ran `deno lint`
with no config, so it had no size or complexity limit, and every Oxlint
limit rule (`complexity`, `max-lines`, `max-params`, `id-length`,
`no-nested-ternary`, `max-lines-per-function`, `max-classes-per-file`,
`max-depth`, `max-nested-callbacks`, `max-statements`,
`import/max-dependencies`, `unicorn/max-nested-calls`) is `"off"` in
`configs.project`. No file is split for size. Do not add new opt-outs
anywhere without asking the user.
- **Default limits stay** (2026-10-16): pedantic's `max-lines` 300,
`max-lines-per-function` 50, `max-classes-per-file` 1,
`max-dependencies` 10, `max-params` 3, `complexity` 20. Don't raise them to
make code pass; refactor (see [[lint-overrides-policy]]). Apps follow this
strictly. The plugin template already turns off `max-lines`,
`max-lines-per-function`, `typescript/prefer-readonly-parameter-types`, and
`typescript/no-unsafe-enum-comparison` because AST visitors fight them; do
not add new opt-outs anywhere without asking the user.
- **`vite.config.ts` names no rule.** Every rule id, built-in ones included,
lives in `configs.project` of `scripts/project-lint.ts`
([[project-lint-plugin]]). `vite.config.ts` only spreads configs.
@@ -40,7 +39,7 @@ lint: {
Hand-written code is never ignored.
- **No lint disable comments**: [[lint-no-disable-comments]].
- **Parity:** [[lint-parity-never-shrinks]]. **Naming:** [[naming-id-match]].
- Don't add a second import sorter; the modules plugin's `enforce-import-order` is
- Don't add a second import sorter; import-check's `enforce-import-order` is
the only one, and Oxfmt's `sortImports` stays off.
`perf` came last: in the template it flagged nine `.includes` on constant
+24 -12
View File
@@ -1,5 +1,5 @@
---
description: No lint overrides for source files; narrow ones only for scripts/**; generated files go in ignorePatterns; size limits match Deno, so nothing is split for size
description: No lint overrides for source files; narrow ones only for scripts/**; generated files go in ignorePatterns; refactor (barrels, splits) instead of raising limits
---
Final policy (2026-10-16). The user considers per-file `overrides` bad
@@ -18,16 +18,28 @@ practice.
## Fix the code instead
Category rule violations are fixed in the code without behavior change,
covered by the tests, inside the same migration PR; e.g. `prefer-set-has`
becomes `const K: ReadonlySet<string> = new Set([...])` with `.has`.
Oxlint's defaults stay; violations are refactored without behavior change,
covered by the tests, inside the same migration PR.
Size and complexity limits are not refactored for: they match what Deno
enforced (2026-10-17, replacing the earlier "refactor to Oxlint's defaults"),
which for bot-agents is none, so those rules are `"off"`
([[lint-config-shape]]).
| Rule | Usual fix |
| ------------------------- | ------------------------------------------------------- |
| `import/max-dependencies` | import a barrel (`#ui`) instead of many direct modules |
| `max-lines` | split the file by responsibility |
| `max-lines-per-function` | extract helpers; split a long Lit template into parts |
| `max-classes-per-file` | one class per file |
| `max-params` | pass one options object |
| `prefer-set-has` | `const K: ReadonlySet<string> = new Set([...])`, `.has` |
Alias rule: an alias may point anywhere and owns its target (a folder's
`index.ts` owns the folder and below, any other file owns only itself); no
two aliases may own the same file, and `../` is banned everywhere
([[imports-aliases-barrels]]).
```ts
// before: 14 imports, over the limit of 10
import { a } from "#ui/button";
import { b } from "#ui/dialog";
// after: one barrel
import { a, b } from "#ui";
```
Measured on memona before the move (Oxlint 1.87, default limits): 921
pedantic hits in 274 files, of which `max-lines-per-function` 206,
`max-lines` 145, `max-classes-per-file` 59, `max-dependencies` 45 (median 14
imports, worst 29). The user chose refactoring over raising limits or
matching memona's old hugoalh limits (2048 lines per file, no function limit).
+1 -1
View File
@@ -29,7 +29,7 @@ Before removing any line from `configs.project`, find what else covers it.
in packages.
- **Vacuous:** JSX rules (no `.tsx` exists) and Deno-only rules.
- **`@hugoalh` rules** map to Oxlint built-ins in `configs.project`.
- **deno-import-check's CLI cycle check** → `import/no-cycle`.
- **import-check's CLI cycle check** → `import/no-cycle`.
The template's list in `configs.project` is the baseline to copy: `curly`,
`guard-for-in`, `no-console`, `no-empty`, `no-regex-spaces`,
+40
View File
@@ -0,0 +1,40 @@
---
description: lit-analyzer runs from scripts/lit-check.ts, which maps "typescript" to the aliased typescript-6 devDependency; npm run lit-check in CI
---
Applies to Lit repos that ran `cunarist/deno-lit-analyzer` under Deno.
lit-analyzer is built on TypeScript's JavaScript compiler API and is run
against TypeScript 6 (the open question was whether it works with TS 7 /
tsgo). The repo installs TS 6 under another name and redirects lit-analyzer
to it, so the rest of the toolchain is unaffected (decided 2026-10-16):
```jsonc
// package.json
"scripts": { "lit-check": "node scripts/lit-check.ts" },
"devDependencies": { "typescript-6": "npm:typescript@^6", "lit-analyzer": "..." }
```
```ts
// scripts/lit-check.ts (shape)
import { registerHooks } from "node:module";
registerHooks({
resolve(specifier, context, next) {
// when lit-analyzer asks for "typescript", hand it TS 6
return next(specifier === "typescript" ? "typescript-6" : specifier, context);
},
});
// then run lit-analyzer
```
- Node runs the `.ts` script directly (type stripping), so no build step.
- **Gotcha:** the obvious `await import("lit-analyzer/cli.js")` fails
`project/no-non-ts-imports` (a `.js` subpath). Import the package entry, or
spawn the package's bin with `node:child_process`, instead.
- CI runs `npm run lit-check` as its own step.
- It is tooling under `scripts/`: no tests; a narrow `overrides` entry for it
(e.g. `no-console`) is allowed if truly needed.
Don't replace this with a global TypeScript downgrade, a second
`tsconfig`, or a `.js` wrapper script.
+26 -13
View File
@@ -1,15 +1,34 @@
---
description: package.json keys follow Oxfmt's standard order (vp check --fix); standard fields only; no peerDependencies or packageManager
description: package.json key order is metadata, scripts, exports, imports, devDependencies, dependencies; standard fields only; no peerDependencies or packageManager
---
## Key order (2026-10-16)
## Key order (2026-10-14)
Oxfmt's standard `package.json` sorting is accepted: there is no
`fmt: { sortPackageJson: false }`, and `vp check --fix` sets the order. This
replaces the earlier hand-written order (metadata, `scripts`, `exports`,
`imports`, `devDependencies`, `dependencies`).
Metadata first, then `scripts`, `exports`, `imports`, `devDependencies`,
`dependencies`. Oxfmt would re-sort the file, so `vite.config.ts` sets
`fmt: { sortPackageJson: false }`; keep it, and keep the order by hand.
The `imports` key order can carry meaning: the modules plugin's
```jsonc
{
"name": "@cunarist/eslint-plugin-x",
"version": "0.1.0",
"description": "...",
"keywords": ["eslint", "eslint-plugin", "eslintplugin", "oxlint"],
"license": "MIT",
"repository": { "type": "git", "url": "git+https://github.com/..." },
"type": "module",
"files": ["dist"],
"engines": { "node": ">=22" },
"eslint-doc-generator": {/* tool options that can only live here */},
"scripts": { "docs": "eslint-doc-generator" },
"exports": { ".": "./dist/index.js" },
"imports": { "#helpers": "./src/helpers/index.ts" },
"devDependencies": { "vite-plus": "^1.1.0" },
"dependencies": {},
}
```
The `imports` key order can carry meaning: import-check's
`enforce-layer-order` reads it as the layer order. Never reorder it casually.
## Fields
@@ -30,9 +49,3 @@ The `imports` key order can carry meaning: the modules plugin's
Refresh the lockfile with `npm install` (or `npm install
--package-lock-only` for a version bump) and commit it with the change.
Alias rule: an alias may point anywhere and owns its target (a folder's
`index.ts` owns the folder and everything below it; any other file owns only
itself). No two aliases may own the same file, outside an area only the exact
alias reaches it, and `../` is banned in every file
([[imports-aliases-barrels]]).
+4 -5
View File
@@ -11,12 +11,11 @@ Oxlint plugin with `meta.name` `"project"`, loaded through `jsPlugins` in
plugin configs: Deno parity rules, extras, per-rule options, and opt-outs,
built-in Oxlint ids included (`curly`, `id-match`, `import/no-cycle`, ...).
- **Custom rules**, enabled as `project/<rule>`. Every repo has
`no-disable-comments` and `no-asset-imports`; bot-agents has no others.
`no-disable-comments` and `no-non-ts-imports`. Apps also port the rules of
their old `scripts/deno-lint-plugin.ts` here under the same names and
behavior (`no-self-sync`, `app-module-conventions`, `restricted-types`, ...).
`scripts/` stays flat; don't add `scripts/rules/`. If the plugin ever needs
more files, they sit flat beside it and share the prefix
(`project-lint-modules.ts`, ...); `scripts/project-lint.ts` keeps the plugin
object and the rule list.
Rules and plugin stay in this one file; don't add `scripts/rules/`.
```ts
import type { DummyRuleMap } from "vite-plus/lint";
+37
View File
@@ -0,0 +1,37 @@
---
description: Packages publish only from CI when a GitHub release vX.Y.Z is published, via npm trusted publishing; never npm publish by hand; notes live only on the release page
---
Standing convention (2026-10-12), applied to every package:
- **Only CI publishes.** Publishing a GitHub release `vX.Y.Z` triggers
`.github/workflows/publish.yml`. Never run `npm publish` locally and never
push a bare `v*` tag. Use the `release` skill, and only when the user asked
for a release.
- **The workflow** checks that the tag equals `v` + `package.json` `version`,
runs the full CI as a reusable workflow (every Node version in the matrix),
then `vp pack` and `npm publish --access public --provenance`. A release
marked pre-release publishes under the `next` dist-tag, others `latest`.
It runs on Node 24 (npm 11, which trusted publishing needs) with no cache.
- **Auth is npm trusted publishing** (OIDC, `id-token: write`), which also
attaches provenance. npm only allows a trusted publisher on a package that
already exists, so the very first release bootstraps with a short-lived
token in the `NPM_TOKEN` repo secret (the workflow uses it only when set).
Afterwards the user configures the trusted publisher for `publish.yml`,
deletes the secret, and sets the package to disallow tokens.
- **Release notes live only on the GitHub release page**
(`--generate-notes`, edited there). There is no `CHANGELOG.md`; never add
one.
- **The version lives in two places in plugin packages:** `package.json` and
`meta.version` in `src/index.ts`; `tests/package-meta.test.ts` fails if they
differ.
- **Before 1.0**, a change that breaks users (renamed rule id, a rule that
fires on more code, removed behavior) is a minor bump.
- **Before a release**, pack the package and lint a known-bad and a known-good
file from separate consumer projects, once through `vp lint` with
`jsPlugins` and once through ESLint 9 with the configs. On the lit plugin
this caught two bugs that 313 unit tests missed.
The cunarist lint plugins (explicit-types, import-check, lit) are published
together, with a token the user provides; the old `deno-*` repos are archived
only after that, one at a time.
+5 -8
View File
@@ -1,5 +1,5 @@
---
description: Vitest via vp test; unit tests sit next to code as *.test.ts, regression/integration tests in tests/ via the public entry (through its # alias, never ../); scripts/ has no tests
description: Vitest via vp test; unit tests sit next to code as *.test.ts, regression/integration tests in tests/ via the public entry; scripts/ has no tests
---
Decided 2026-10-14 for all repos.
@@ -11,15 +11,12 @@ Decided 2026-10-14 for all repos.
config needed. Packages publish only `dist/`, so colocated tests cost nothing
in the tarball.
- **Regression and integration tests** live in the top-level `tests/` folder
and use only the public entry point, imported through its `#` alias (e.g.
`#plugin` for `./src/plugin/index.ts`), never internals and never `../`,
which is banned in every file. `vp test tests` runs just these
([[imports-aliases-barrels]]). In bot-agents, `tests/` imports only
`#testing` (`./src/testing/index.ts`), plain helpers that import no source
module.
and use only the public entry point (an alias like `#plugin`), never
internals. `vp test tests` runs just these.
- **No in-source tests** (`import.meta.vitest`).
- **`scripts/` gets no tests** (2026-10-15): it is repo tooling, not
production code (`project-lint.ts`, `protect-source.sh`).
production code (`project-lint.ts`, `decorators.ts`, `lit-check.ts`,
release helpers).
- Test files follow every lint rule; no relaxed overrides for tests.
## Migrating Deno tests
+2 -2
View File
@@ -44,8 +44,8 @@ before any push. CI installs the toolchain with `voidzero-dev/setup-vp`.
- `vp check` does **not** report `isolatedDeclarations` errors (TS9010); only
`vp pack` does, so packages run `vp pack` in CI on every PR.
- Oxfmt sorts `package.json` keys by default; that order is accepted, so
there is no `fmt` option for it ([[package-json-shape]]).
- Oxfmt sorts `package.json` keys by default, which destroys the required key
order. Set `fmt: { sortPackageJson: false }` ([[package-json-shape]]).
- Oxlint and Oxfmt skip what `.gitignore` lists; that is the only reason
`dist/` and `node_modules/` are not linted. Keep build output gitignored.
- Vite+ bundles `vite.config.ts` itself and resolves `#` imports in it.
+9 -14
View File
@@ -1,5 +1,5 @@
---
description: Every file is .ts (no js/mjs/cjs/jsx/tsx, configs included); imports name only .ts files or .js/.mts/.mjs package subpaths; CSS comes from HTML, JSON/SVG become .ts modules
description: Every file is .ts (no js/mjs/cjs/jsx/tsx, configs included) and source imports only .ts; CSS comes from HTML, JSON/SVG become .ts modules
---
The user's rule, from the Deno days and restated for Node (2026-10-12): "No
@@ -12,22 +12,17 @@ plain `.ts`. Build output in a gitignored folder is the only JS.
`noUncheckedIndexedAccess`). If a tool or framework seems to need a JS or TSX
file, stop and flag it instead of adding one.
## No asset imports (2026-10-14, widened 2026-10-16 and 2026-10-17)
## Only `.ts` imports (2026-10-14)
`project/no-asset-imports` (in `scripts/project-lint.ts`; it was
`no-non-ts-imports` before 2026-10-16) fails:
`project/no-non-ts-imports` (in `scripts/project-lint.ts`) fails:
- a relative, absolute, `#`, or package-subpath specifier whose last segment
has an extension other than `.ts`, `.js`, `.mts`, or `.mjs`: `.css`,
`.json`, `.svg`, `.jsx`, `.tsx`, and every other asset;
has a non-`.ts` extension: `.css`, `.json`, `.svg`, `.js`, `.mjs`, `.tsx`;
- any `?query` suffix (`?raw`, `?url`, `?inline`);
- any import attributes (`with { type: "json" }`, `with { type: "text" }`).
`.js`, `.mts`, and `.mjs` are allowed (user decision, 2026-10-17) for package
subpaths such as `lit/decorators.js` or `pdfjs-dist/legacy/build/pdf.mjs`;
our own files are still `.ts` only. `.jsx` and `.tsx` stay banned. Bare
packages (`lit`, `@types/json-schema`, `lodash.debounce`), `#` aliases, and
`node:` builtins pass. `@fortawesome/x/svgs/a.svg` fails.
Bare packages (`lit`, `@types/json-schema`, `lodash.debounce`), `#` aliases,
and `node:` builtins pass. `@fortawesome/x/svgs/a.svg` fails.
```ts
import "./theme.css"; // no
@@ -42,12 +37,12 @@ import { scenes } from "#data/scenes"; // yes: scenes.ts
import CSS from TS, not even for Vite. (This also removes most of the
`import/no-unassigned-import` hits the `suspicious` category reports.)
- **JSON data:** turn it into a `.ts` module exporting a typed constant.
- **SVG:** a `.ts` module exporting the markup.
- **SVG:** a `.ts` module exporting a Lit template; see [[icons-memona-style]].
- **`package.json` fields at runtime:** never `import pkg from
"./package.json"` and there is no `#package` alias. Write the value out in
`.ts` and add a test in `tests/` that reads `package.json` with `node:fs`
and fails on drift (the plugin repos do this for `meta.name`/`meta.version`).
- **Reading a file is not importing it.** `readFileSync` + `JSON.parse` is
fine in tooling and tests.
- **A tool's CLI file:** prefer the package entry or spawning the package's
bin over importing `pkg/cli.js`, which runs the CLI as a side effect.
- **A tool's `.js` entry:** don't `import("pkg/cli.js")`; use the package
entry or spawn the package's bin ([[lit-analyzer-ts6]]).
+69
View File
@@ -0,0 +1,69 @@
---
name: migrate-deno-repo
description: Checklist for moving a Deno repo (or a leftover Deno part of one) to Node 22+ + Vite+ with npm, same behavior, full lint parity, one PR
---
Template: `cunarist/eslint-plugin-explicit-types` on `main`. Read its
`AGENTS.md`, `vite.config.ts`, `scripts/project-lint.ts`, `package.json`, and
CI before starting, and copy their shape. Never ask the user mid-way when
they're away: decide by the memories, record the choice in the PR.
Branch `node-migration` (apps) or `initial-port` (new plugin repos); one PR
with everything.
**Inventory first**
- [ ] List every `deno.json` task, import-map entry, lint rule (tags,
includes, `@hugoalh`, custom plugins), test, `Deno.*` call, JSON/CSS/SVG
import, and every place the output is consumed (Rust, HTML,
Containerfile, CI).
**Toolchain**
- [ ] `package.json` in the required key order, `engines.node ">=22"`, no
`packageManager`, no `peerDependencies`; `npm install`; commit
`package-lock.json`.
- [ ] `tsconfig.json` from the template; `vite.config.ts` with `fmt:
{ sortPackageJson: false }` and the category-based `lint` block.
- [ ] Delete `deno.json`, `deno.lock`, and Deno-only files. `mod.ts` →
`index.ts`; `deno.json` imports → `package.json` `imports` (`#x/*`
patterns for trailing-slash mappings); `jsr:`/`npm:` → npm packages.
**Code**
- [ ] Only `.ts` files; no non-`.ts` imports: CSS via `<link>`, JSON and SVG
as `.ts` modules, icons in memona's style.
- [ ] `Deno.*` → `node:*` / `process`. Generated JS written as `.ts`, built,
gitignored.
- [ ] Decorators stay standard with `accessor`; add `scripts/decorators.ts`
if the repo has any. Lit repos: `scripts/lit-check.ts` + `npm run
lit-check`.
**Lint**
- [ ] `scripts/project-lint.ts` copied; old local Deno lint rules ported into
it; parity mapping for every former rule; `id-match`; no disable
comments; no source `overrides`; refactor size-rule hits.
**Tests**
- [ ] `Deno.test` → Vitest (codemod, then finish TODOs); unit tests next to
code, integration in `tests/`; no case dropped.
**Build, ship, CI**
- [ ] `vp build` in app mode with the same output paths, or `vp pack` for
packages. Containerfile node stage on `node:24-alpine`. CI with
`setup-vp`: `vp check`, build, `vp test` (+ lit-check). Packages:
release-triggered `publish.yml`.
- [ ] `AGENTS.md` with the damem header, `.agents/` notes from the damem kit,
README updated (Node.js 22+, no Deno instructions). `damem doctor`
clean.
**Finish**
- [ ] `vp check`, `vp test`, build all pass locally; the app runs.
- [ ] Commit in plain imperative sentences; open the PR (summary, changes,
parity mapping, deviations/risks, "CI fails until @cunarist/eslint-plugin-*
are published" if relevant, verification, footer line).
- [ ] Run `review-cycle`. Never merge, publish, or archive.
-18
View File
@@ -19,24 +19,6 @@ dependency. Keep the sources to erasable syntax and `.ts` imports, which
type-checks, and `vp test` runs Vitest. Unit tests sit next to the code as
`src/*.test.ts`; repository-level tests live in `tests/`.
A `#` alias in `package.json` `imports` may point anywhere and owns its
target: an alias to a folder's `index.ts` owns that folder and everything
below it; an alias to any other file owns only that file. No overlap: no file
may be owned by 2 aliases, so no alias inside another alias's area and no 2
aliases with the same target. Outside an area, only the exact alias reaches
it; a deeper alias specifier or a relative path into the area is a bypass.
Inside an area, relative imports of siblings and subfolders are free. `../`
is banned in every file, owned or not
(`import/no-relative-parent-imports`). Files no alias owns need no `index.ts`
and no alias, and import `./` siblings and subfolders freely. The one alias
is `#testing` (`./src/testing/index.ts`): every test imports it by alias, and
`tests/` reaches `src/` only through it. Local convention, stricter than the
general rule above: in this repo, aliases point only at a direct subfolder of
`src/` (`"#helpers": "./src/helpers/index.ts"`), and code outside `src/` uses
`./` relative imports. `scripts/` is flat: one file per
tool, no subfolders and no `index.ts`, imported directly by file path
([.agents/memory/imports-aliases-barrels.md](.agents/memory/imports-aliases-barrels.md)).
The only config files are `package.json`, `tsconfig.json`, and
`vite.config.ts`, plus `package-lock.json`. Repo-local lint rules and the rule
list live in `scripts/project-lint.ts`. Lint disable comments are not allowed;
+3 -6
View File
@@ -1,10 +1,10 @@
{
"name": "bot-agents",
"private": true,
"description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.",
"private": true,
"type": "module",
"imports": {
"#testing": "./src/testing/index.ts"
"engines": {
"node": ">=24.2"
},
"devDependencies": {
"@eslint/core": "^0.17.0",
@@ -12,8 +12,5 @@
"@typescript-eslint/types": "^8.71.2",
"vite-plus": "^1.2.0",
"yaml": "^2.9.1"
},
"engines": {
"node": ">=24.2"
}
}
+31 -65
View File
@@ -1,7 +1,8 @@
/**
* `project` — lint rules this repository holds its own source to, on top of
* the Oxlint categories in `vite.config.ts`. Its rules of its own are
* `no-disable-comments` and `no-asset-imports`.
* `project` — lint rules this repository holds its own source to. Deno lint
* ran here with its `recommended` rules and no plugins; the Oxlint categories
* in `vite.config.ts` plus the rule list below keep at least that coverage.
* Its rules of its own are `no-disable-comments` and `no-non-ts-imports`.
*
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry.
*
@@ -9,7 +10,7 @@
*/
import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core";
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/types";
import type { TSESTree } from "@typescript-eslint/types";
import type { DummyRuleMap } from "vite-plus/lint";
/** The source code both hosts hand a rule, with the comment list. */
@@ -72,19 +73,11 @@ const noDisableCommentsRule: ProjectRule = {
const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u;
/**
* Script module extensions an import may name: our `.ts` files and `.js`,
* `.mts`, or `.mjs` package subpaths. `.cts`, `.cjs`, `.tsx`, and `.jsx` are
* not among them.
* Whether `source` names something other than a `.ts` module: a query string
* such as `?raw`, or a final path segment with any other extension. Bare
* package names are left alone, dots and all, as are extensionless paths.
*/
const SCRIPT_EXTENSIONS: ReadonlySet<string> = new Set([".ts", ".js", ".mts", ".mjs"]);
/**
* Whether `source` names an asset rather than a script module: a query string
* such as `?raw`, or a final path segment whose extension, from its last dot,
* is not `.ts`, `.js`, `.mts`, or `.mjs`. Bare package names are left alone,
* dots and all, as are extensionless paths.
*/
function isAssetSource(source: string): boolean {
function isNonTsSource(source: string): boolean {
if (source.includes("?")) {
return true;
}
@@ -99,34 +92,30 @@ function isAssetSource(source: string): boolean {
if (lastSegment === "." || lastSegment === ".." || !lastSegment.includes(".")) {
return false;
}
const extension: string = lastSegment.slice(lastSegment.lastIndexOf("."));
return !SCRIPT_EXTENSIONS.has(extension);
return !lastSegment.endsWith(".ts");
}
/**
* `no-asset-imports` — rejects importing anything but a script module: a
* `no-non-ts-imports` — rejects importing anything but a `.ts` module: a
* source path, static or a dynamic `import()` with a string literal, ending in
* an extension other than `.ts`, `.js`, `.mts`, or `.mjs` (`.css`, `.json`,
* `.svg`, `.jsx`, `.tsx`, `.cjs`) or carrying a query string (`?raw`, `?url`),
* and any import or export with import attributes. `.js`, `.mts`, and `.mjs`
* cover package subpaths such as `lit/decorators.js` or
* `pdfjs-dist/legacy/build/pdf.mjs`; our own files are still `.ts` only. Bare
* package names, `#` aliases, and `node:` builtins
* without an extension pass; package subpaths are checked like paths.
* another extension (`.css`, `.json`, `.svg`, `.js`, `.tsx`) or carrying a
* query string (`?raw`, `?url`), and any import or export with import
* attributes. Bare package names, `#` aliases, and `node:` builtins without an
* extension pass; package subpaths are checked like paths.
*/
const noAssetImportsRule: ProjectRule = {
const noNonTsImportsRule: ProjectRule = {
meta: {
type: "problem",
docs: { description: "Disallow importing assets instead of .ts, .js, .mts, or .mjs modules" },
docs: { description: "Disallow importing modules other than .ts files" },
schema: [],
messages: {
extension: "Only .ts, .js, .mts and .mjs modules may be imported.",
extension: "Only .ts modules may be imported.",
attributes: "Import attributes are not allowed.",
},
},
create(context) {
function checkSource(source: TSESTree.StringLiteral): void {
if (isAssetSource(source.value)) {
if (isNonTsSource(source.value)) {
context.report({ node: source, messageId: "extension" });
}
}
@@ -149,7 +138,7 @@ const noAssetImportsRule: ProjectRule = {
ExportNamedDeclaration: checkDeclaration,
ExportAllDeclaration: checkDeclaration,
ImportExpression(node: TSESTree.ImportExpression): void {
if (node.source.type === AST_NODE_TYPES.Literal && typeof node.source.value === "string") {
if (node.source.type === "Literal" && typeof node.source.value === "string") {
checkSource(node.source);
}
if (node.options !== null) {
@@ -161,9 +150,10 @@ const noAssetImportsRule: ProjectRule = {
};
/**
* This repository's own rule list, spread by `vite.config.ts`: rules that
* sit outside the Oxlint categories, plus the template's extras. Built-in
* Oxlint rule ids sit here too, so `vite.config.ts` lists no rule by name.
* This repository's own rule list, spread by `vite.config.ts`: Deno's
* `recommended` rules that sit outside the Oxlint categories, plus the
* template's extras. Built-in Oxlint rule ids sit here too, so
* `vite.config.ts` lists no rule by name.
*/
const rules: DummyRuleMap = {
// The explicit-types template's `camelcase` stand-in: no `_` inside a name
@@ -174,7 +164,7 @@ const rules: DummyRuleMap = {
"^_*[^_]*_*$|^[^a-z]*$",
{ properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true },
],
// Rules outside the categories.
// Deno's `recommended` rules outside the categories.
"no-array-constructor": "error",
"no-case-declarations": "error",
"no-empty": "error",
@@ -199,7 +189,8 @@ const rules: DummyRuleMap = {
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "error",
// On Node, these come from an explicit `node:` import instead of the global scope.
// Deno's `no-window`, `no-process-global`, and `no-node-globals`: on Node,
// these come from an explicit `node:` import instead of the global scope.
"no-restricted-globals": [
"error",
"window",
@@ -211,39 +202,14 @@ const rules: DummyRuleMap = {
],
// The explicit-types template's extras.
"import/no-cycle": "error",
// `../` is banned in every file; anything above a file needs a `#` alias.
"import/no-relative-parent-imports": "error",
"typescript/consistent-type-imports": "error",
"typescript/no-import-type-side-effects": "error",
"typescript/no-non-null-assertion": "error",
// No disable comments at all.
// Deno's `ban-untagged-ignore` and `ban-unused-ignore`, made stricter: no
// disable comments at all.
"project/no-disable-comments": "error",
"project/no-asset-imports": "error",
"project/no-non-ts-imports": "error",
"unicorn/no-abusive-eslint-disable": "error",
// Size and complexity limits match Deno's: this repo's `deno lint` ran the
// `recommended` set with no config file, so no size or complexity rule
// applied and none applies here. Each Oxlint limit rule, from a category or
// not, is off:
// - `complexity`: Deno had no `max-complexity`.
// - `max-lines`: Deno had no `max-file-lines`.
// - `max-params`: Deno had no `max-params`.
// - `id-length`: Deno had no `max-identifier-length`.
// - `no-nested-ternary`: Deno had no `max-nest-ternaries`.
// - `max-lines-per-function`, `max-classes-per-file`, `max-depth`,
// `max-nested-callbacks`, `max-statements`, `import/max-dependencies`,
// `unicorn/max-nested-calls`: Deno had none of these.
complexity: "off",
"max-lines": "off",
"max-params": "off",
"id-length": "off",
"no-nested-ternary": "off",
"max-lines-per-function": "off",
"max-classes-per-file": "off",
"max-depth": "off",
"max-nested-callbacks": "off",
"max-statements": "off",
"import/max-dependencies": "off",
"unicorn/max-nested-calls": "off",
// Pedantic, but Node's and the web's own types (`Response`, `Stats`,
// `RequestInit`) are mutable, so no parameter of theirs can pass; the
// explicit-types template and the app repos turn it off too.
@@ -258,7 +224,7 @@ const plugin = {
meta: { name: "project" },
rules: {
"no-disable-comments": noDisableCommentsRule,
"no-asset-imports": noAssetImportsRule,
"no-non-ts-imports": noNonTsImportsRule,
},
configs: { project: { rules } },
};
+119
View File
@@ -0,0 +1,119 @@
// Runs Claude Code for one profile and returns its final result text.
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
import type { Readable } from "node:stream";
import { isClaudeAuthFailure } from "./auth.ts";
import type { ClaudeFailureFields } from "./auth.ts";
import { exitCode } from "./child.ts";
import { isObject } from "./json.ts";
import type { JsonObject } from "./json.ts";
import { AgentAuthError, agentEnvironment } from "./profiles.ts";
import { PROCESS_ENV, WORKSPACE } from "./run-context.ts";
import type { RuntimeProfile } from "./runtime.ts";
// Claude Code's stream-json `result` event, in its own field names.
type ClaudeResult = ClaudeFailureFields &
Partial<Record<"is_error", boolean>> & {
subtype: string;
};
// Echoes stderr as it arrives and resolves with all of it.
async function copyError(stream: Readable): Promise<string> {
stream.setEncoding("utf8");
let error = "";
for await (const text of stream) {
error += String(text);
console.error(text);
}
return error;
}
// Prints the visible text of each streamed message part.
function printParts(event: JsonObject): void {
const content = isObject(event.message) ? event.message.content : undefined;
for (const part of Array.isArray(content) ? content : []) {
if (isObject(part)) {
const text = part.thinking ?? part.text ?? part.name;
if (typeof text === "string" && text !== "") {
console.log(text);
}
}
}
}
function toResult(event: JsonObject): ClaudeResult {
const { is_error: isError, result, subtype, api_error_status: apiErrorStatus } = event;
return {
...(typeof isError === "boolean" && { is_error: isError }),
...(typeof result === "string" && { result }),
subtype: String(subtype),
...((typeof apiErrorStatus === "number" || apiErrorStatus === null) && {
api_error_status: apiErrorStatus,
}),
};
}
// Prints each streamed message part and returns the final `result` event.
async function readEvents(stream: Readable): Promise<ClaudeResult | undefined> {
let result: ClaudeResult | undefined;
for await (const line of createInterface({ input: stream, crlfDelay: Infinity })) {
if (line !== "") {
const event: unknown = JSON.parse(line);
if (isObject(event)) {
printParts(event);
if (event.type === "result") {
result = toResult(event);
}
}
}
}
return result;
}
function checkResult(result: ClaudeResult | undefined, error: string): string {
if (result?.is_error === true) {
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
if (isClaudeAuthFailure(result)) {
throw new AgentAuthError("claude sign-in failed", { cause: failure });
}
throw failure;
}
if (result?.result === undefined) {
throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`);
}
return result.result;
}
export async function runClaude(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const args = [
"--print",
"--dangerously-skip-permissions",
"--model",
profile.model,
"--output-format",
"stream-json",
"--verbose",
prompt,
];
const claude = spawn("claude", args, {
cwd: WORKSPACE,
env: { ...agentEnvironment(PROCESS_ENV, profile, token), IS_SANDBOX: "1" },
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
});
const status = exitCode(claude);
const errorText = copyError(claude.stderr);
const result = await readEvents(claude.stdout);
const [code, error] = await Promise.all([status, errorText]);
const body = checkResult(result, error);
if (code !== 0) {
throw new Error(error.trim() || `claude exited with ${code}`);
}
return body;
}
+184
View File
@@ -0,0 +1,184 @@
// Signs Codex in for one profile, runs it, and returns its result file path.
import { spawn } from "node:child_process";
import process from "node:process";
import type { Readable } from "node:stream";
import { finished } from "node:stream/promises";
import { setTimeout as sleep } from "node:timers/promises";
import { isCodexAuthFailure, retryInvalidToken } from "./auth.ts";
import type { CodexAuthentication } from "./auth.ts";
import { echo, exitCode } from "./child.ts";
import { AgentAuthError, agentEnvironment, withProfile } from "./profiles.ts";
import {
makeProfileFile,
postAuthorComment,
PROCESS_ENV,
runAgentCommand,
WORKSPACE,
} from "./run-context.ts";
import type { RuntimeProfile } from "./runtime.ts";
interface CodexAttempt {
code: number;
error: string;
}
async function collectText(stream: Readable, append: (chunk: string) => void): Promise<void> {
stream.setEncoding("utf8");
for await (const chunk of stream) {
append(String(chunk));
}
}
// Polls every 100 ms until the login prints something, failing if it exits first.
async function pollForInstructions(
shownText: () => string,
status: Promise<number>,
): Promise<void> {
if (shownText().trim() !== "") {
return;
}
const exited = await Promise.race([status.then(() => true), sleep(100, false)]);
if (exited) {
throw new AgentAuthError("AI bot login produced no instructions");
}
await pollForInstructions(shownText, status);
}
async function codexDeviceLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
): Promise<void> {
const login = spawn("codex", ["login", "--device-auth"], {
cwd: WORKSPACE,
env: environment,
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
});
const status = exitCode(login);
let shown = "";
const append = (chunk: string): void => {
shown += chunk;
};
const drained = Promise.all([
collectText(login.stdout, append),
collectText(login.stderr, append),
]);
await sleep(3000);
await pollForInstructions(() => shown, status);
await postAuthorComment(withProfile(shown, profile));
const [code] = await Promise.all([status, drained]);
if (code !== 0) {
throw new AgentAuthError(`codex login exited with ${code}`);
}
}
async function codexApiLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
token: string,
): Promise<void> {
const login = spawn("codex", ["login", "--with-api-key"], {
cwd: WORKSPACE,
env: environment,
uid: profile.uid,
gid: profile.gid,
stdio: ["pipe", "inherit", "pipe"],
});
const status = exitCode(login);
login.stdin.end(`${token}\n`);
await finished(login.stdin);
let error = "";
const [code] = await Promise.all([
status,
collectText(login.stderr, (chunk) => {
error += chunk;
}),
]);
if (code !== 0) {
throw new Error(error.trim() || `codex login exited with ${code}`);
}
}
async function signIn(
profile: RuntimeProfile,
environment: Record<string, string>,
authentication: CodexAuthentication,
): Promise<void> {
if (authentication.kind === "api-key") {
await codexApiLogin(profile, environment, authentication.token);
return;
}
const check = spawn("codex", ["login", "status"], {
cwd: WORKSPACE,
env: environment,
uid: profile.uid,
gid: profile.gid,
stdio: "ignore",
});
if ((await exitCode(check)) !== 0) {
await codexDeviceLogin(profile, environment);
}
}
async function execCodex(
profile: RuntimeProfile,
environment: Record<string, string>,
prompt: string,
outputPath: string,
): Promise<CodexAttempt> {
const args = [
"exec",
"--model",
profile.model,
"--dangerously-bypass-approvals-and-sandbox",
"--output-last-message",
outputPath,
prompt,
];
const codex = spawn("codex", args, {
cwd: WORKSPACE,
env: environment,
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "inherit", "pipe"],
});
const status = exitCode(codex);
codex.stderr.setEncoding("utf8");
let error = "";
for await (const chunk of codex.stderr) {
const text = String(chunk);
await echo(process.stderr, text);
error += text;
}
// Codex echoes the prompt, whose comments may quote auth errors.
return { code: await status, error: error.replaceAll(prompt, "") };
}
export async function runCodex(
profile: RuntimeProfile,
prompt: string,
authentication: CodexAuthentication,
): Promise<string> {
const token = authentication.kind === "api-key" ? authentication.token : undefined;
const environment = agentEnvironment(PROCESS_ENV, profile, token);
await signIn(profile, environment, authentication);
const outputPath = await makeProfileFile(profile, "result.md");
const attempt = (): Promise<CodexAttempt> => execCodex(profile, environment, prompt, outputPath);
const result =
authentication.kind === "api-key"
? await attempt()
: await retryInvalidToken(attempt, async () => {
await runAgentCommand(profile, "codex", ["logout"]);
await codexDeviceLogin(profile, environment);
});
if (result.code !== 0) {
const failure = new Error(result.error.trim() || `codex exited with ${result.code}`);
if (isCodexAuthFailure(result.error)) {
throw new AgentAuthError("codex sign-in failed", { cause: failure });
}
throw failure;
}
return outputPath;
}
+41
View File
@@ -0,0 +1,41 @@
// Runs OpenCode for one profile and returns its final visible text.
import { spawn } from "node:child_process";
import process from "node:process";
import { echo, exitCode } from "./child.ts";
import {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
import { agentEnvironment } from "./profiles.ts";
import { PROCESS_ENV, WORKSPACE } from "./run-context.ts";
import type { RuntimeProfile } from "./runtime.ts";
export async function runOpenCode(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const child = spawn("opencode", openCodeArgs(profile.model, prompt), {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
});
const status = exitCode(child);
const collected = collectOpenCodeOutput(
child.stdout,
child.stderr,
(chunk) => echo(process.stdout, chunk),
(chunk) => echo(process.stderr, chunk),
);
const [code, streams] = await Promise.all([status, collected]);
const error = streams.stderr.trim();
if (code !== 0) {
throw openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${code}`);
}
return parseOpenCodeEvents(streams.stdout);
}
+30
View File
@@ -0,0 +1,30 @@
// Dispatches one attempt to the agent CLI its profile names.
import { runClaude } from "./agent-claude.ts";
import { runCodex } from "./agent-codex.ts";
import { runOpenCode } from "./agent-opencode.ts";
import { codexAuthentication } from "./auth.ts";
import type { CodexAuthentication } from "./auth.ts";
import type { RuntimeProfile } from "./runtime.ts";
export interface AgentAnswer {
body?: string;
resultPath?: string;
}
// Runs the selected agent; Codex leaves its answer in a file, the others return it.
export async function runAgent(
profile: RuntimeProfile,
token: string | undefined,
prompt: string,
authentication: CodexAuthentication | null,
): Promise<AgentAnswer> {
if (profile.type === "claude") {
return { body: await runClaude(profile, token ?? "", prompt) };
}
if (profile.type === "codex") {
return {
resultPath: await runCodex(profile, prompt, authentication ?? codexAuthentication(token)),
};
}
return { body: await runOpenCode(profile, token ?? "", prompt) };
}
+1 -1
View File
@@ -9,7 +9,7 @@ import {
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts";
test("retries once after an invalid refresh token", async () => {
let attempts = 0;
+10 -5
View File
@@ -60,6 +60,15 @@ export async function retryInvalidToken<T extends { code: number; error: string
return result;
}
function hasCodexTokens(tokens: unknown): boolean {
return (
isObject(tokens) &&
["access_token", "refresh_token", "id_token", "account_id"].every(
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
)
);
}
async function readCodexAuth(path: string): Promise<string> {
const stat = await lstat(path);
if (!stat.isFile() || stat.isSymbolicLink()) {
@@ -75,15 +84,11 @@ async function readCodexAuth(path: string): Promise<string> {
} catch {
throw new Error("invalid Codex login");
}
const tokens = isObject(value) ? value.tokens : undefined;
if (
!isObject(value) ||
typeof value.auth_mode !== "string" ||
value.auth_mode === "" ||
!isObject(tokens) ||
!["access_token", "refresh_token", "id_token", "account_id"].every(
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
)
!hasCodexTokens(value.tokens)
) {
throw new Error("invalid Codex login");
}
+1 -1
View File
@@ -7,7 +7,7 @@ import process from "node:process";
import { isNotFound } from "./errors.ts";
import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts";
import type { RuntimeProfile } from "./runtime.ts";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "./testing.ts";
const profile = (uid: number, home: string): RuntimeProfile => ({
index: uid - 20_000,
+14 -6
View File
@@ -27,19 +27,27 @@ async function profileProcessIds(uid: number): Promise<number[]> {
.map(Number);
}
function killProcess(pid: number): void {
try {
process.kill(pid, "SIGKILL");
} catch (error) {
if (!isNotFound(error)) {
throw error;
}
}
}
// Kills the UID's processes every 10 ms until none is left, for up to 100 rounds.
export async function killProfileProcesses(uid: number, attempt = 0): Promise<void> {
if (attempt === 100) {
throw new Error(`profile UID ${uid} still owns processes after cleanup`);
}
const ids = await profileProcessIds(uid);
if (ids.length === 0) return;
if (ids.length === 0) {
return;
}
for (const pid of ids) {
try {
process.kill(pid, "SIGKILL");
} catch (error) {
if (!isNotFound(error)) throw error;
}
killProcess(pid);
}
await sleep(10);
await killProfileProcesses(uid, attempt + 1);
+114
View File
@@ -0,0 +1,114 @@
// Reads what the agent phase left behind: its outcome, and the review and
// anchors files it wrote into a profile home.
import type { Stats } from "node:fs";
import { lstat, readFile } from "node:fs/promises";
import { join } from "node:path";
import { isNotFound } from "./errors.ts";
import { isObject } from "./json.ts";
import { isFailureReason, isProfileType } from "./profiles.ts";
import type { ProfileFailure } from "./profiles.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
export interface Anchor {
path: string;
line: number;
side: "new" | "old";
body: string;
}
function parseAnchor(entry: unknown, at: string): Anchor {
if (!isObject(entry)) {
throw new TypeError(`${at}: not an object`);
}
const { path, line, side = "new", body } = entry;
if (typeof path !== "string" || path === "") {
throw new TypeError(`${at}.path: expected a repository path`);
}
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
throw new TypeError(`${at}.line: expected a line number`);
}
if (side !== "new" && side !== "old") {
throw new Error(`${at}.side: expected "new" or "old"`);
}
if (typeof body !== "string" || body.trim() === "") {
throw new TypeError(`${at}.body: expected the finding`);
}
return { path, line, side, body };
}
export function parseAnchors(text: string, sourcePath: string): Anchor[] {
const entries: unknown = JSON.parse(text);
if (!Array.isArray(entries)) {
throw new TypeError(`${sourcePath}: not an array`);
}
return entries.map((entry: unknown, index) => parseAnchor(entry, `${sourcePath}[${index}]`));
}
// Only known labels reach the comment, never raw agent output.
function knownFailures(failures: unknown): ProfileFailure[] {
return (Array.isArray(failures) ? failures : []).flatMap((entry: unknown) => {
if (!isObject(entry)) {
return [];
}
const { type, model, reason } = entry;
return isProfileType(type) && typeof model === "string" && isFailureReason(reason)
? [{ type, model, reason }]
: [];
});
}
export async function readOutcome(path: string): Promise<RuntimeOutcome> {
const value: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isObject(value) || !("ok" in value)) {
throw new Error("invalid agent outcome");
}
if (value.ok === false) {
return { ok: false, failures: knownFailures(value.failures) };
}
const { ok, body, reviewPath, anchorsPath, profile } = value;
if (
ok !== true ||
typeof body !== "string" ||
typeof reviewPath !== "string" ||
typeof anchorsPath !== "string" ||
!isObject(profile) ||
!isProfileType(profile.type) ||
typeof profile.model !== "string"
) {
throw new Error("invalid agent outcome");
}
return {
ok: true,
body,
reviewPath,
anchorsPath,
profile: { type: profile.type, model: profile.model },
};
}
export async function readAgentArtifact(
manifest: RuntimeManifest,
path: string,
name: string,
): Promise<string | null> {
const profile = manifest.profiles.find((entry) => join(entry.home, name) === path);
if (profile === undefined) throw new Error(`${name}: outside profile home`);
let stat: Stats;
try {
stat = await lstat(path);
} catch (error) {
if (isNotFound(error)) return null;
throw error;
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== profile.uid ||
(stat.mode & 0o077) !== 0
) {
throw new Error(`${name}: expected a profile-owned regular private file`);
}
const text = await readFile(path, "utf8");
return text === "" ? null : text;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { isNotFound } from "./errors.ts";
import { postRuntime } from "./post.ts";
import type { PostContext } from "./post.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts";
const context: PostContext = {
apiUrl: "https://gitea.example/api/v1",
+118 -173
View File
@@ -1,12 +1,11 @@
import type { Stats } from "node:fs";
import { lstat, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { readFile, rm } from "node:fs/promises";
import process from "node:process";
import { isNotFound } from "./errors.ts";
import { isObject } from "./json.ts";
import { parseAnchors, readAgentArtifact, readOutcome } from "./post-artifacts.ts";
import type { Anchor } from "./post-artifacts.ts";
import { failureFooter, withProfile } from "./profiles.ts";
import type { FailureReason, ProfileFailure } from "./profiles.ts";
import type { ProfileFailure } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
@@ -17,11 +16,15 @@ export interface PostContext {
eventName: string;
}
interface Anchor {
path: string;
line: number;
side: "new" | "old";
body: string;
type SuccessOutcome = Extract<RuntimeOutcome, { ok: true }>;
type GiteaRequest = (path: string, body: unknown) => Promise<void>;
// The reviewer's view of one issue or pull request.
interface Publisher {
context: PostContext;
request: GiteaRequest;
postComment(body: string): Promise<void>;
}
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
@@ -30,108 +33,125 @@ const VERDICTS: Record<string, [event: string, mark: string]> = {
Approved: ["APPROVED", "✅"],
"Changes requested": ["REQUEST_CHANGES", "🛑"],
};
const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]);
const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]);
const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value);
const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value);
const failureComment = (failures: ProfileFailure[]): string =>
`AI bot failed.\n\n${failureFooter(failures)}`;
class GiteaRequestError extends Error {}
function parseAnchors(text: string, sourcePath: string): Anchor[] {
const entries: unknown = JSON.parse(text);
if (!Array.isArray(entries)) throw new TypeError(`${sourcePath}: not an array`);
return entries.map((entry: unknown, index) => {
const at = `${sourcePath}[${index}]`;
if (!isObject(entry)) {
throw new TypeError(`${at}: not an object`);
function giteaRequest(apiUrl: string, reviewerToken: string, fetcher: typeof fetch): GiteaRequest {
return async (path, body) => {
let response: Response;
try {
response = await fetcher(`${apiUrl}/${path}`, {
method: "POST",
headers: {
Authorization: `token ${reviewerToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new GiteaRequestError(`${path}: ${detail}`, { cause: error });
}
const { path, line, side = "new", body } = entry;
if (typeof path !== "string" || path === "") {
throw new TypeError(`${at}.path: expected a repository path`);
if (!response.ok) {
throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`);
}
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
throw new TypeError(`${at}.line: expected a line number`);
}
if (side !== "new" && side !== "old") {
throw new Error(`${at}.side: expected "new" or "old"`);
}
if (typeof body !== "string" || body.trim() === "") {
throw new TypeError(`${at}.body: expected the finding`);
}
return { path, line, side, body };
});
}
async function readOutcome(path: string): Promise<RuntimeOutcome> {
const value: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isObject(value) || !("ok" in value)) {
throw new Error("invalid agent outcome");
}
const outcome = value;
if (outcome.ok === false) {
// Only known labels reach the comment, never raw agent output.
const failures: unknown[] = Array.isArray(outcome.failures) ? outcome.failures : [];
return {
ok: false,
failures: failures.flatMap((entry: unknown) => {
const { type, model, reason } = isObject(entry) ? entry : {};
return isProfileType(type) && typeof model === "string" && isFailureReason(reason)
? [{ type, model, reason }]
: [];
}),
};
}
if (
outcome.ok !== true ||
typeof outcome.body !== "string" ||
typeof outcome.reviewPath !== "string" ||
typeof outcome.anchorsPath !== "string" ||
!isObject(outcome.profile)
) {
throw new Error("invalid agent outcome");
}
const profile = outcome.profile;
if (!isProfileType(profile.type) || typeof profile.model !== "string") {
throw new Error("invalid agent outcome");
}
return {
ok: true,
body: outcome.body,
reviewPath: outcome.reviewPath,
anchorsPath: outcome.anchorsPath,
profile: {
type: profile.type,
model: profile.model,
},
};
}
async function readAgentArtifact(
async function readSuccess(
manifest: RuntimeManifest,
path: string,
name: string,
): Promise<string | null> {
const profile = manifest.profiles.find((entry) => join(entry.home, name) === path);
if (profile === undefined) throw new Error(`${name}: outside profile home`);
let stat: Stats;
publisher: Publisher,
): Promise<SuccessOutcome> {
let outcome: RuntimeOutcome;
try {
stat = await lstat(path);
outcome = await readOutcome(manifest.outcomeFile);
} catch (error) {
if (isNotFound(error)) return null;
await publisher.postComment(failureComment([]));
throw new Error("agent phase failed", { cause: error });
}
if (!outcome.ok) {
await publisher.postComment(failureComment(outcome.failures));
throw new Error("agent phase failed");
}
return outcome;
}
async function postReview(
publisher: Publisher,
outcome: SuccessOutcome,
review: string,
anchors: Anchor[],
): Promise<void> {
const { context, request } = publisher;
const [verdict = "", ...rest] = review.split("\n");
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
const send = async (entries: Anchor[]): Promise<void> => {
await request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
body: stripAnsi(
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile),
),
event,
comments: entries.map(({ path, line, side, body }) => ({
path,
body: stripAnsi(withProfile(body, outcome.profile)),
new_position: side === "new" ? line : 0,
old_position: side === "old" ? line : 0,
})),
});
};
await send(anchors).catch(async (error: unknown) => {
if (anchors.length === 0) throw error;
console.error(
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
);
await send([]);
});
}
async function publishResult(
manifest: RuntimeManifest,
outcome: SuccessOutcome,
publisher: Publisher,
): Promise<void> {
const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md");
if (review === null) {
if (publisher.context.eventName === "pull_request") {
throw new Error("agent did not write a pull request review");
}
await publisher.postComment(withProfile(outcome.body, outcome.profile));
return;
}
const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json");
const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath);
await postReview(publisher, outcome, review, anchors);
}
async function publish(
manifest: RuntimeManifest,
context: PostContext,
fetcher: typeof fetch,
): Promise<void> {
const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim();
const request = giteaRequest(context.apiUrl, reviewerToken, fetcher);
const publisher: Publisher = {
context,
request,
postComment: async (body) => {
await request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
body: stripAnsi(body),
});
},
};
const outcome = await readSuccess(manifest, publisher);
try {
await publishResult(manifest, outcome, publisher);
} catch (error) {
if (error instanceof GiteaRequestError) throw error;
await publisher.postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
throw error;
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== profile.uid ||
(stat.mode & 0o077) !== 0
) {
throw new Error(`${name}: expected a profile-owned regular private file`);
}
const text = await readFile(path, "utf8");
return text === "" ? null : text;
}
export async function postRuntime(
@@ -141,82 +161,7 @@ export async function postRuntime(
): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath);
try {
const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim();
const request = async (path: string, body: unknown): Promise<void> => {
let response: Response;
try {
response = await fetcher(`${context.apiUrl}/${path}`, {
method: "POST",
headers: {
Authorization: `token ${reviewerToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new GiteaRequestError(`${path}: ${detail}`, { cause: error });
}
if (!response.ok) {
throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`);
}
};
const postComment = (body: string): Promise<void> =>
request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
body: stripAnsi(body),
});
let outcome: RuntimeOutcome;
try {
outcome = await readOutcome(manifest.outcomeFile);
} catch (error) {
await postComment(failureComment([]));
throw new Error("agent phase failed", { cause: error });
}
if (!outcome.ok) {
await postComment(failureComment(outcome.failures));
throw new Error("agent phase failed");
}
try {
const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md");
if (review === null) {
if (context.eventName === "pull_request") {
throw new Error("agent did not write a pull request review");
}
await postComment(withProfile(outcome.body, outcome.profile));
return;
}
const [verdict = "", ...rest] = review.split("\n");
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json");
const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath);
const postReview = (entries: Anchor[]): Promise<void> =>
request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
body: stripAnsi(
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile),
),
event,
comments: entries.map(({ path, line, side, body }) => ({
path,
body: stripAnsi(withProfile(body, outcome.profile)),
new_position: side === "new" ? line : 0,
old_position: side === "old" ? line : 0,
})),
});
await postReview(anchors).catch(async (error: unknown) => {
if (anchors.length === 0) throw error;
console.error(
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
);
await postReview([]);
});
} catch (error) {
if (error instanceof GiteaRequestError) throw error;
await postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
throw error;
}
await publish(manifest, context, fetcher);
} finally {
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => {
if (!isNotFound(error)) throw error;
+3 -1
View File
@@ -5,7 +5,9 @@ import { prepareRuntime } from "./runtime.ts";
const env = (name: string): string => {
const value = process.env[name];
if (value === undefined || value === "") throw new Error(`${name} is not set`);
if (value === undefined || value === "") {
throw new Error(`${name} is not set`);
}
return value;
};
+14 -1
View File
@@ -1,9 +1,18 @@
export type ProfileType = "claude" | "codex" | "opencode";
export interface BotProfile {
type: "claude" | "codex" | "opencode";
type: ProfileType;
model: string;
token?: string;
}
const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]);
const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]);
export function isProfileType(value: unknown): value is ProfileType {
return PROFILE_TYPES.has(value);
}
export function profileFooter(profile: Pick<BotProfile, "type" | "model">): string {
return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
}
@@ -14,6 +23,10 @@ export function withProfile(body: string, profile: Pick<BotProfile, "type" | "mo
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
export function isFailureReason(value: unknown): value is FailureReason {
return FAILURE_REASONS.has(value);
}
export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
reason: FailureReason;
};
+83
View File
@@ -0,0 +1,83 @@
// The agent phase's environment and the helpers every agent runner shares. It
// is read once, when the runner starts, as the single-file runner did.
import { spawn } from "node:child_process";
import { chmod, chown, writeFile } from "node:fs/promises";
import process from "node:process";
import { exitCode } from "./child.ts";
import { agentEnvironment, sanitizedEnvironment } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeProfile } from "./runtime.ts";
export const env = (name: string): string => {
const value = process.env[name];
if (value === undefined) {
throw new Error(`${name} is not set`);
}
return value;
};
function definedEnvironment(): Record<string, string> {
const result: Record<string, string> = {};
for (const [name, value] of Object.entries(process.env)) {
if (value !== undefined) {
result[name] = value;
}
}
return result;
}
export const API = env("GITEA_API_URL");
export const REPO = env("GITEA_REPOSITORY");
export const INDEX = env("ISSUE_INDEX");
export const EVENT = env("EVENT_NAME");
const AUTHOR_TOKEN = env("GITEA_TOKEN");
export const WORKSPACE = env("GITHUB_WORKSPACE");
export const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST"));
export const PROCESS_ENV = sanitizedEnvironment(definedEnvironment());
export async function gitea(path: string, body?: unknown): Promise<Response> {
const response = await fetch(`${API}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
Authorization: `token ${AUTHOR_TOKEN}`,
"Content-Type": "application/json",
},
body: body === undefined ? null : JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${path}: ${response.status} ${await response.text()}`);
}
return response;
}
export async function postAuthorComment(body: string): Promise<void> {
await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body });
}
export async function runAgentCommand(
profile: RuntimeProfile,
command: string,
args: string[],
token?: string,
): Promise<void> {
const child = spawn(command, args, {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
uid: profile.uid,
gid: profile.gid,
stdio: "ignore",
});
const code = await exitCode(child);
if (code !== 0) {
throw new Error(`${command} ${args[0]} exited with ${code}`);
}
}
export async function makeProfileFile(profile: RuntimeProfile, name: string): Promise<string> {
const path = `${profile.home}/${name}`;
await writeFile(path, "", { mode: 0o600 });
await chmod(path, 0o600);
await chown(path, profile.uid, profile.gid);
return path;
}
+50 -382
View File
@@ -1,41 +1,29 @@
// Runs one ordered agent profile. Credential decryption and reviewer posting
// live in separate processes so neither privileged secret reaches this runner.
import { spawn } from "node:child_process";
import { chmod, chown, readFile, writeFile } from "node:fs/promises";
import process from "node:process";
import { createInterface } from "node:readline";
import type { Readable } from "node:stream";
import { text as readText } from "node:stream/consumers";
import { finished } from "node:stream/promises";
import { setTimeout as sleep } from "node:timers/promises";
import { chmod, readFile, writeFile } from "node:fs/promises";
import {
codexAuthentication,
isClaudeAuthFailure,
isCodexAuthFailure,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
import type { ClaudeFailureFields, CodexAuthentication } from "./auth.ts";
import { echo, exitCode } from "./child.ts";
import { codexAuthentication, persistCodexAuth, stageCodexAuth } from "./auth.ts";
import { runAgent } from "./agents.ts";
import type { AgentAnswer } from "./agents.ts";
import { exitCode } from "./child.ts";
import { killProfileProcesses, readProfileFile } from "./isolation.ts";
import { isObject } from "./json.ts";
import {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
import {
AgentAuthError,
agentEnvironment,
runProfiles,
sanitizedEnvironment,
withProfile,
} from "./profiles.ts";
import { runProfiles } from "./profiles.ts";
import type { ProfileFailure } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import {
API,
env,
EVENT,
gitea,
INDEX,
makeProfileFile,
MANIFEST,
PROCESS_ENV,
REPO,
runAgentCommand,
WORKSPACE,
} from "./run-context.ts";
import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts";
interface AttemptResult {
@@ -45,62 +33,21 @@ interface AttemptResult {
profile: Pick<RuntimeProfile, "type" | "model">;
}
// Claude Code's stream-json `result` event, in its own field names.
type ClaudeResult = ClaudeFailureFields &
Partial<Record<"is_error", boolean>> & {
subtype: string;
};
const env = (name: string): string => {
const value = process.env[name];
if (value === undefined) throw new Error(`${name} is not set`);
return value;
};
function definedEnvironment(): Record<string, string> {
const result: Record<string, string> = {};
for (const [name, value] of Object.entries(process.env)) {
if (value !== undefined) {
result[name] = value;
}
}
return result;
interface RenderedPrompt {
prompt: string;
reviewPath: string;
anchorsPath: string;
}
const API = env("GITEA_API_URL");
const REPO = env("GITEA_REPOSITORY");
const INDEX = env("ISSUE_INDEX");
const EVENT = env("EVENT_NAME");
const AUTHOR_TOKEN = env("GITEA_TOKEN");
const WORKSPACE = env("GITHUB_WORKSPACE");
const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST"));
const PROCESS_ENV = sanitizedEnvironment(definedEnvironment());
const RULES_PATH = "repos/commons/code-rules/raw/README.md";
const CODEX_AUTH = "/root/.codex/auth.json";
const SKILLS_INSTALL = "skills add obra/superpowers";
async function gitea(path: string, body?: unknown): Promise<Response> {
const response = await fetch(`${API}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
Authorization: `token ${AUTHOR_TOKEN}`,
"Content-Type": "application/json",
},
body: body === undefined ? null : JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${path}: ${response.status} ${await response.text()}`);
}
return response;
}
async function postAuthorComment(body: string): Promise<void> {
await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body });
}
async function runRoot(command: string, args: string[]): Promise<void> {
const code = await exitCode(spawn(command, args, { env: PROCESS_ENV, stdio: "ignore" }));
if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`);
if (code !== 0) {
throw new Error(`${command} ${args[0]} exited with ${code}`);
}
}
async function configureGitAuthor(): Promise<void> {
@@ -117,23 +64,6 @@ async function readProfileToken(profile: RuntimeProfile): Promise<string | undef
return token;
}
async function runAgentCommand(
profile: RuntimeProfile,
command: string,
args: string[],
token?: string,
): Promise<void> {
const child = spawn(command, args, {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
uid: profile.uid,
gid: profile.gid,
stdio: "ignore",
});
const code = await exitCode(child);
if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`);
}
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
await runAgentCommand(profile, "git", [
"config",
@@ -159,18 +89,17 @@ function formatComment(comment: unknown): string {
return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
}
async function renderPrompt(
profile: RuntimeProfile,
): Promise<{ prompt: string; reviewPath: string; anchorsPath: string }> {
const reviewPath = await makeProfileFile(profile, "review.md");
const anchorsPath = await makeProfileFile(profile, "anchors.json");
async function promptValues(
reviewPath: string,
anchorsPath: string,
): Promise<Record<string, string>> {
const comments: unknown = await (
await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`)
).json();
if (!Array.isArray(comments)) {
throw new TypeError("comments: unexpected Gitea response");
}
const values: Record<string, string> = {
return {
COMMENT: env("COMMENT"),
ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"),
CODE_RULES: await (await gitea(RULES_PATH)).text(),
@@ -181,6 +110,12 @@ async function renderPrompt(
REVIEW_PATH: reviewPath,
ANCHORS_PATH: anchorsPath,
};
}
async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
const reviewPath = await makeProfileFile(profile, "review.md");
const anchorsPath = await makeProfileFile(profile, "anchors.json");
const values = await promptValues(reviewPath, anchorsPath);
const template = await readFile(new URL("../prompt.md", import.meta.url), "utf8");
return {
prompt: template.replaceAll(
@@ -192,312 +127,45 @@ async function renderPrompt(
};
}
async function runClaude(profile: RuntimeProfile, token: string, prompt: string): Promise<string> {
const claude = spawn(
"claude",
[
"--print",
"--dangerously-skip-permissions",
"--model",
profile.model,
"--output-format",
"stream-json",
"--verbose",
prompt,
],
{
cwd: WORKSPACE,
env: {
...agentEnvironment(PROCESS_ENV, profile, token),
IS_SANDBOX: "1",
},
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
},
);
const status = exitCode(claude);
let result: ClaudeResult | undefined;
let error = "";
const copyError = (async (): Promise<void> => {
claude.stderr.setEncoding("utf8");
for await (const chunk of claude.stderr) {
const part = String(chunk);
error += part;
console.error(part);
}
})();
for await (const line of createInterface({ input: claude.stdout, crlfDelay: Infinity })) {
if (line !== "") {
const event: unknown = JSON.parse(line);
if (isObject(event)) {
const content = isObject(event.message) ? event.message.content : undefined;
for (const part of Array.isArray(content) ? content : []) {
if (isObject(part)) {
const text = part.thinking ?? part.text ?? part.name;
if (typeof text === "string" && text !== "") {
console.log(text);
}
}
}
if (event.type === "result") {
const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event;
result = {
...(typeof isError === "boolean" && { is_error: isError }),
...(typeof answer === "string" && { result: answer }),
subtype: String(subtype),
...((typeof apiStatus === "number" || apiStatus === null) && {
api_error_status: apiStatus,
}),
};
}
}
}
}
const [code] = await Promise.all([status, copyError]);
if (result?.is_error === true) {
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
if (isClaudeAuthFailure(result)) {
throw new AgentAuthError("claude sign-in failed", { cause: failure });
}
throw failure;
}
if (result?.result === undefined) {
throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`);
}
if (code !== 0) {
throw new Error(error.trim() || `claude exited with ${code}`);
}
return result.result;
}
// Polls every 100 ms until the login prints something, failing if it exits first.
async function pollForInstructions(
shownText: () => string,
status: Promise<number>,
): Promise<void> {
if (shownText().trim() !== "") {
return;
}
const exited = await Promise.race([status.then(() => true), sleep(100, false)]);
if (exited) {
throw new AgentAuthError("AI bot login produced no instructions");
}
await pollForInstructions(shownText, status);
}
async function codexDeviceLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
): Promise<void> {
const login = spawn("codex", ["login", "--device-auth"], {
cwd: WORKSPACE,
env: environment,
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
});
const status = exitCode(login);
let shown = "";
const collect = async (stream: Readable): Promise<void> => {
stream.setEncoding("utf8");
for await (const chunk of stream) {
shown += String(chunk);
}
};
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
await sleep(3000);
await pollForInstructions(() => shown, status);
await postAuthorComment(withProfile(shown, profile));
const [code] = await Promise.all([status, drained]);
if (code !== 0) {
throw new AgentAuthError(`codex login exited with ${code}`);
}
}
async function codexApiLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
token: string,
): Promise<void> {
const login = spawn("codex", ["login", "--with-api-key"], {
cwd: WORKSPACE,
env: environment,
uid: profile.uid,
gid: profile.gid,
stdio: ["pipe", "inherit", "pipe"],
});
const status = exitCode(login);
login.stdin.end(`${token}\n`);
await finished(login.stdin);
const [code, error] = await Promise.all([status, readText(login.stderr)]);
if (code !== 0) {
throw new Error(error.trim() || `codex login exited with ${code}`);
}
}
async function makeProfileFile(profile: RuntimeProfile, name: string): Promise<string> {
const path = `${profile.home}/${name}`;
await writeFile(path, "", { mode: 0o600 });
await chmod(path, 0o600);
await chown(path, profile.uid, profile.gid);
return path;
}
async function runCodex(
profile: RuntimeProfile,
prompt: string,
authentication: CodexAuthentication,
): Promise<string> {
const token = authentication.kind === "api-key" ? authentication.token : undefined;
const environment = agentEnvironment(PROCESS_ENV, profile, token);
if (authentication.kind === "api-key") {
await codexApiLogin(profile, environment, authentication.token);
} else {
const check = spawn("codex", ["login", "status"], {
cwd: WORKSPACE,
env: environment,
uid: profile.uid,
gid: profile.gid,
stdio: "ignore",
});
if ((await exitCode(check)) !== 0) await codexDeviceLogin(profile, environment);
}
const outputPath = await makeProfileFile(profile, "result.md");
const attempt = async (): Promise<{ code: number; error: string }> => {
const codex = spawn(
"codex",
[
"exec",
"--model",
profile.model,
"--dangerously-bypass-approvals-and-sandbox",
"--output-last-message",
outputPath,
prompt,
],
{
cwd: WORKSPACE,
env: environment,
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "inherit", "pipe"],
},
);
const status = exitCode(codex);
codex.stderr.setEncoding("utf8");
let error = "";
for await (const chunk of codex.stderr) {
const part = String(chunk);
await echo(process.stderr, part);
error += part;
}
// Codex echoes the prompt, whose comments may quote auth errors.
return { code: await status, error: error.replaceAll(prompt, "") };
};
const result =
authentication.kind === "api-key"
? await attempt()
: await retryInvalidToken(attempt, async () => {
await runAgentCommand(profile, "codex", ["logout"]);
await codexDeviceLogin(profile, environment);
});
if (result.code !== 0) {
const failure = new Error(result.error.trim() || `codex exited with ${result.code}`);
if (isCodexAuthFailure(result.error)) {
throw new AgentAuthError("codex sign-in failed", { cause: failure });
}
throw failure;
}
return outputPath;
}
async function runOpenCode(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const child = spawn("opencode", openCodeArgs(profile.model, prompt), {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
});
const status = exitCode(child);
const collected = collectOpenCodeOutput(
child.stdout,
child.stderr,
(chunk) => echo(process.stdout, chunk),
(chunk) => echo(process.stderr, chunk),
);
const [code, streams] = await Promise.all([status, collected]);
const error = streams.stderr.trim();
if (code !== 0) {
throw openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${code}`);
}
return parseOpenCodeEvents(streams.stdout);
}
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
const token = await readProfileToken(profile);
const authentication = profile.type === "codex" ? codexAuthentication(token) : null;
let stagedCodexAuth: string | null = null;
let body: string | undefined;
let resultPath: string | null = null;
let reviewPath: string | undefined;
let anchorsPath: string | undefined;
let answer: AgentAnswer = {};
let rendered: RenderedPrompt | undefined;
try {
if (authentication?.kind === "persistent") {
stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid);
}
await prepareProfile(profile);
const rendered = await renderPrompt(profile);
reviewPath = rendered.reviewPath;
anchorsPath = rendered.anchorsPath;
if (profile.type === "claude") {
body = await runClaude(profile, token ?? "", rendered.prompt);
} else if (profile.type === "codex") {
resultPath = await runCodex(
profile,
rendered.prompt,
authentication ?? codexAuthentication(token),
);
} else {
body = await runOpenCode(profile, token ?? "", rendered.prompt);
}
rendered = await renderPrompt(profile);
answer = await runAgent(profile, token, rendered.prompt, authentication);
} finally {
await killProfileProcesses(profile.uid);
if (stagedCodexAuth !== null) {
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
}
}
if (resultPath !== null) body = await readProfileFile(profile, resultPath);
if (body === undefined || reviewPath === undefined || anchorsPath === undefined) {
const body =
answer.resultPath === undefined
? answer.body
: await readProfileFile(profile, answer.resultPath);
if (body === undefined || rendered === undefined) {
throw new Error("agent attempt produced no result");
}
return {
body,
reviewPath,
anchorsPath,
profile: { type: profile.type, model: profile.model },
};
const { reviewPath, anchorsPath } = rendered;
return { body, reviewPath, anchorsPath, profile: { type: profile.type, model: profile.model } };
}
async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), {
mode: 0o600,
});
await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), { mode: 0o600 });
await chmod(MANIFEST.outcomeFile, 0o600);
}
const failures: ProfileFailure[] = [];
try {
await configureGitAuthor();
await writeOutcome({
ok: true,
...(await runProfiles(MANIFEST.profiles, runProfile, failures)),
});
await writeOutcome({ ok: true, ...(await runProfiles(MANIFEST.profiles, runProfile, failures)) });
} catch (error) {
console.error(error);
await writeOutcome({ ok: false, failures });
+1 -1
View File
@@ -7,7 +7,7 @@ import { assert, expect, test } from "vite-plus/test";
import { prepareRuntime } from "./runtime.ts";
import type { RuntimeProfile } from "./runtime.ts";
import { IS_ROOT, makeTempDir } from "#testing";
import { IS_ROOT, makeTempDir } from "./testing.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
+22 -23
View File
@@ -39,6 +39,24 @@ async function writePrivate(path: string, value: string, uid: number, gid: numbe
await chown(path, uid, gid);
}
async function prepareProfile(
runtimeDir: string,
profile: BotProfile,
index: number,
): Promise<RuntimeProfile> {
const uid = FIRST_PROFILE_UID + index;
const home = join(runtimeDir, `profile-${index}`);
await mkdir(home, { mode: 0o700 });
await chmod(home, 0o700);
await chown(home, uid, uid);
const tokenFile =
profile.token === undefined || profile.token === "" ? null : join(home, "token");
if (tokenFile !== null && profile.token !== undefined) {
await writePrivate(tokenFile, profile.token, uid, uid);
}
return { index, type: profile.type, model: profile.model, uid, gid: uid, home, tokenFile };
}
export async function prepareRuntime(
actionPath: string,
runtimeRoot: string,
@@ -56,29 +74,10 @@ export async function prepareRuntime(
// One profile at a time, in order, as each claims the next UID.
const profiles = await credentials.profiles.reduce<Promise<RuntimeProfile[]>>(
async (previous, profile, index) => {
const prepared = await previous;
const uid = FIRST_PROFILE_UID + index;
const home = join(runtimeDir, `profile-${index}`);
await mkdir(home, { mode: 0o700 });
await chmod(home, 0o700);
await chown(home, uid, uid);
const tokenFile =
profile.token === undefined || profile.token === "" ? null : join(home, "token");
if (tokenFile !== null && profile.token !== undefined) {
await writePrivate(tokenFile, profile.token, uid, uid);
}
prepared.push({
index,
type: profile.type,
model: profile.model,
uid,
gid: uid,
home,
tokenFile,
});
return prepared;
},
async (previous, profile, index) => [
...(await previous),
await prepareProfile(runtimeDir, profile, index),
],
Promise.resolve([]),
);
+23
View File
@@ -0,0 +1,23 @@
// Test support: the current user's IDs and a fresh temporary directory.
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import process from "node:process";
export function currentUid(): number {
return process.getuid?.() ?? -1;
}
export function currentGid(): number {
return process.getgid?.() ?? -1;
}
// The isolation tests switch UIDs, which only root may do.
export const IS_ROOT: boolean = currentUid() === 0;
// Symlinks and POSIX modes, as these tests use them, need a Unix host.
export const IS_WINDOWS: boolean = process.platform === "win32";
export function makeTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), "bot-agents-test-"));
}
-47
View File
@@ -1,47 +0,0 @@
// Test support: the current user's IDs, a fresh temporary directory, and a
// plain command runner. It imports no source module, so the repository-level
// tests in tests/, which reach src/ only through `#testing`, never touch
// internals.
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import process from "node:process";
import { text } from "node:stream/consumers";
export function currentUid(): number {
return process.getuid?.() ?? -1;
}
export function currentGid(): number {
return process.getgid?.() ?? -1;
}
// The isolation tests switch UIDs, which only root may do.
export const IS_ROOT: boolean = currentUid() === 0;
// Symlinks and POSIX modes, as these tests use them, need a Unix host.
export const IS_WINDOWS: boolean = process.platform === "win32";
export function makeTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), "bot-agents-test-"));
}
export interface CommandResult {
success: boolean;
stderr: string;
}
// Runs a command with stdin closed and only the given environment, and
// collects its stderr.
export async function runCommand(
command: string,
args: string[],
env: Record<string, string>,
): Promise<CommandResult> {
const child = spawn(command, args, { env, stdio: ["ignore", "ignore", "pipe"] });
const [stderr, closed] = await Promise.all([text(child.stderr), once(child, "close")]);
const exitCode: unknown = closed[0];
return { success: exitCode === 0, stderr };
}
+8 -5
View File
@@ -1,10 +1,9 @@
import { spawnSync } from "node:child_process";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { expect, test } from "vite-plus/test";
import { runCommand } from "#testing";
// Each phase runs as `node src/<phase>.ts` with no build step. Node loads the
// whole import graph, stripping types, before the phase reads its first
// variable, so failing on that variable proves every module loads.
@@ -14,9 +13,13 @@ const PHASES: [phase: string, firstVariable: string][] = [
["post", "BOT_AGENTS_MANIFEST"],
];
test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => {
test.each(PHASES)("Node loads the %s phase", (phase, firstVariable) => {
const path = fileURLToPath(new URL(`../src/${phase}.ts`, import.meta.url));
const result = await runCommand(process.execPath, [path], { PATH: process.env.PATH ?? "" });
expect(result.success).toBe(false);
const result = spawnSync(process.execPath, [path], {
env: { PATH: process.env.PATH ?? "" },
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(`${firstVariable} is not set`);
});
+15 -13
View File
@@ -1,15 +1,14 @@
import { spawnSync } from "node:child_process";
import { readdir, readFile, rm, stat } from "node:fs/promises";
import process from "node:process";
import { assert, expect, test } from "vite-plus/test";
import { parse } from "yaml";
import { makeTempDir, runCommand } from "#testing";
type JsonObject = Record<string, unknown>;
const isMapping = (value: unknown): value is JsonObject =>
typeof value === "object" && value !== null && !Array.isArray(value);
import { isNotFound } from "../src/errors.ts";
import { isMapping } from "../src/json.ts";
import type { JsonObject } from "../src/json.ts";
import { makeTempDir } from "../src/testing.ts";
const mapping = (value: unknown, at: string): JsonObject => {
assert(isMapping(value), at);
@@ -77,13 +76,16 @@ test("the source protection script stages immutable action files", async () => {
const temp = await makeTempDir();
const outputFile = `${temp}/output`;
try {
const result = await runCommand("/bin/bash", ["scripts/protect-source.sh"], {
...process.env,
ACTION_PATH: process.cwd(),
GITHUB_OUTPUT: outputFile,
RUNNER_TEMP: temp,
const result = spawnSync("/bin/bash", ["scripts/protect-source.sh"], {
env: {
...process.env,
ACTION_PATH: process.cwd(),
GITHUB_OUTPUT: outputFile,
RUNNER_TEMP: temp,
},
encoding: "utf8",
});
assert(result.success, result.stderr);
assert(result.status === 0, result.stderr);
const path = (await readFile(outputFile, "utf8")).trim().slice(5);
const files = [
@@ -152,7 +154,7 @@ test("uses the standard Skills installer without custom discovery", async () =>
assert(runner.includes("skills add obra/superpowers"));
const removed = await stat("superpowers.ts").then(
() => false,
(error: unknown) => error instanceof Error && "code" in error && error.code === "ENOENT",
(error: unknown) => isNotFound(error),
);
assert(removed, "superpowers.ts should be removed");
});
+7
View File
@@ -3,11 +3,18 @@ import { defineConfig } from "vite-plus";
import projectLint from "./scripts/project-lint.ts";
export default defineConfig({
// Keep package.json in its hand-written order.
fmt: { sortPackageJson: false },
lint: {
plugins: ["typescript", "unicorn", "oxc", "import"],
jsPlugins: ["./scripts/project-lint.ts"],
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
rules: projectLint.configs.project.rules,
// Repo tooling, not app code: the lint plugin compares AST node `type`s,
// which the AST types declare as an enum.
overrides: [
{ files: ["scripts/**"], rules: { "typescript/no-unsafe-enum-comparison": "off" } },
],
options: {
typeAware: true,
typeCheck: true,