Release 1.6.0: 1.0 cutover, phases 0–5 and the review remediation (#151)

- 1.0 cutover: the rewrite monorepo lands on main. 1.1.5 through 1.5.0
  never published on their own and ship here as 1.6.0, in lockstep for
  es-core, es-cli and epistemic-swarm (ADR 0001).
- Phase 0 (#140): fail-closed seat models, one CLI grammar for the
  human-only rule, sealed source-cache readers, pinned MCP identity, a
  registry-generated permission probe suite, one package list.
- Phase 1 (#141): research backends with failover, the Scraper-Swarm
  gateway, callable brainstorm/harvest, research-only runs, the
  deep-research coordinator, Markdown/HTML dossiers, domain-pack tiers.
- Phase 2 (#142): ADR 0002 and the shared approveStage service, masked
  TUI approvals, the headless runner (JSONL events, metrics, cancel).
- Phase 3 (#144): the human-only es-fleet daemon, DAG scheduler, worktree
  coordinator with a gated integration branch, telemetry bus and watch.
- Phase 4 (#146): the loopback web control plane (SolidJS, ADR 0003):
  dashboard, browser approvals, config preview, evidence explorer.
- Phase 5 (#149): telemetry harvest, distillation into proposals, the
  self-dogfood preset.
- Cutover review remediation (#152): CLI approvals bound to the preview
  with attempt limiting (#143), human-only `factory run` and fleet token,
  DNS-resolving SSRF guard, value-level secret scrubbing, fleet gc and
  allocation races, CodeQL #84, boundary-gate gaps, one REPORT.md writer
  per run mode.

Closes #96.
Closes #97.
Closes #98.
Closes #99.
Closes #100.
Closes #101.
Closes #103.
Closes #106.
Closes #107.
Closes #108.
Closes #109.
Closes #110.
Closes #111.
Closes #112.
Closes #113.
Closes #114.
Closes #117.
Closes #118.
Closes #119.
Closes #120.
Closes #122.
Closes #123.
Closes #124.
Closes #125.
Closes #126.
Closes #127.
Closes #129.
Closes #130.
Closes #131.
Closes #132.
Closes #133.
Closes #135.
Closes #136.
Closes #147.
Closes #148.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
BillyOutlast
2026-10-09 21:33:44 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7091f2c313
commit 020ba87f08
297 changed files with 28806 additions and 691 deletions
+9 -20
View File
@@ -1,22 +1,11 @@
# IUMBTEMS (Epistemic Swarm): Project Workspace Rules
# IUMBTEMS (Epistemic Swarm): session notes
This repository operates under the **IUMBTEMS Epistemic Integrity Protocol**. All Claude Code sessions within this directory are bound by strict evidentiary integrity rules.
The single source of truth for agent conventions in this repo is
[`AGENTS.md`](../AGENTS.md) at the repo root. Follow it: evidence tags on
research/harvest claims, explicit file staging (never `git add -A`), `bun run
check` before merging, and the contract invariants (human-only approvals,
control files, seat scopes).
## Core Rules for This Workspace
1. **Epistemic Invariant Enforcement**:
- Tag every factual claim using `[VERIFIED: <hash|URL>]`, `[INFERRED: <reasoning>]`, `[HYPOTHESIS: <test>]`, or `[NEGATIVE_KNOWLEDGE: <query>]`.
- Never generate hallucinated citations. If an empirical claim cannot be retrieved and verified via tools, mark it as `[HYPOTHESIS]` or `[NEGATIVE_KNOWLEDGE]`.
2. **Source Caching**:
- When fetching articles, documentation, or papers via web search or MCP tools, ensure the raw content is hashed and saved to `.research/sources/<sha256>.md` with metadata in `.research/sources/<sha256>.json`.
- Verbatim quotes must match the text inside `.research/sources/<sha256>.md`.
3. **Multi-Agent Dialectic Swarm**:
- When running deep research, use the runner script:
```bash
python3 runner/research_swarm.py --mode <research|audit|scout|hybrid|brainstorm|darkharvest> --objective "<Research Question>"
```
- For preliminary planning and assumption inversion, trigger the Socratic grilling skill via `/grilling`.
4. **IPC Scratchpad Structure**:
- Read and write session data strictly through `.research/scratchpads/{scope_id}/`.
Do not follow stale local instructions: there is no `runner/` directory, no
`.research/` tree (evidence lives under `.factory/research/`), and no
per-claim tag convention for everyday agent communication.
+34
View File
@@ -0,0 +1,34 @@
name: Fleet
# Path-filtered job for packages/fleet (ADR 0001): fleet changes get fast
# feedback here, while the `check` job in CI stays unfiltered and keeps
# running on every push and PR (#122).
on:
push:
branches: [rewrite, main, "integration/**", "ticket-*"]
paths:
- "packages/fleet/**"
- ".github/workflows/fleet.yml"
pull_request:
paths:
- "packages/fleet/**"
- ".github/workflows/fleet.yml"
permissions:
contents: read
jobs:
fleet:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.4.2"
- run: bun install --frozen-lockfile --ignore-scripts
# Local lockfile-pinned binary (not bun x: S8543, no unverified downloads).
- run: ./node_modules/.bin/biome check packages/fleet
- run: bun run typecheck
- run: bun test packages/fleet
+7 -2
View File
@@ -46,6 +46,10 @@ jobs:
# workspace:* to real versions), smoke-test the installed tarballs, and
# publish exactly those tarballs.
- run: scripts/pack-smoke.sh packs
# The release tarballs, in publish order, from the single source of
# truth (scripts/packages.ts); pack-smoke.sh packed exactly these.
- id: packages
run: echo "tarballs=$(bun scripts/packages.ts --release | awk '{print "./packs/"$2"-*.tgz"}' | tr '\n' ' ')" >> "$GITHUB_OUTPUT"
# Trusted publishing needs a recent npm CLI; it picks up the OIDC token
# from the environment automatically (no NODE_AUTH_TOKEN). Pinned, and no
# lifecycle scripts from the fetched tarball run.
@@ -54,11 +58,12 @@ jobs:
env:
DRY_RUN: ${{ inputs.dry_run }}
run: |
# Dependency order: core, then the CLI and the OpenCode plugin.
# Publish order comes from scripts/packages.ts (core, then the CLI
# and the OpenCode plugin): the glob expands each step's tarball.
# NOTE: the ./ prefix is load-bearing. A bare packs/foo.tgz is
# parsed as a GitHub user/repo shorthand (git ls-remote failure);
# ./packs/foo.tgz is resolved as the local tarball.
for tgz in ./packs/heretek-ai-es-core-*.tgz ./packs/heretek-ai-es-cli-*.tgz ./packs/heretek-ai-epistemic-swarm-*.tgz; do
for tgz in ${{ steps.packages.outputs.tarballs }}; do
echo "::group::npm publish $tgz"
if [ "$DRY_RUN" = "true" ]; then
npm publish "$tgz" --provenance --access public --dry-run
+38
View File
@@ -0,0 +1,38 @@
name: Web
# Path-filtered job for packages/web (ADR 0001, ADR 0003): web changes get
# fast feedback here, while the `check` job in CI stays unfiltered and keeps
# running on every push and PR (#129).
on:
push:
branches: [rewrite, main, "integration/**", "ticket-*"]
paths:
- "packages/web/**"
- ".github/workflows/web.yml"
pull_request:
paths:
- "packages/web/**"
- ".github/workflows/web.yml"
permissions:
contents: read
jobs:
web:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.4.2"
- run: bun install --frozen-lockfile --ignore-scripts
# Local lockfile-pinned binary (not bun x: S8543, no unverified downloads).
- run: ./node_modules/.bin/biome check packages/web
- run: bun run typecheck
# Browser conditions: the web tests render with the client Solid build
# (the default toolchain resolves its server build, whose render throws).
- run: bun --conditions=browser test packages/web
- run: bun run build
working-directory: packages/web
+6
View File
@@ -21,3 +21,9 @@ evals/results/
audit.md
*.pyc
__pycache__/
# Fleet task worktrees (one isolated checkout per task, #124)
.fleet/
# Tier-1 phase worktrees (one checkout per ticket, merged into integration/*)
.worktrees/
+20 -4
View File
@@ -22,28 +22,44 @@ spike as proof).
- `packages/opencode/src/**` — the plugin (server + TUI entrypoints). The
canonical agent registry (`packages/core/src/agents/registry.ts`) compiles to
v2 agents with wildcard-deny scoping; never touch built-ins or defaults.
- `packages/fleet/src/**` — the human-only fleet daemon (`es-fleet`, private
until its RPC is stable). Depends on core only; core, CLI and plugin never
import it (`scripts/deps.ts` boundaries + a path-filtered CI job).
`es-fleet status` is agent-safe, but `es-fleet status --token` prints the
bus bearer token at an interactive terminal only (human-only, like every
other fleet verb).
- `packages/web/src/**` — the web control plane (SolidJS + Vite, private,
never published; ADR 0003). Served by the fleet daemon on loopback behind
a single-use ticket exchange; a leaf surface — nothing imports it, and it
imports nothing at runtime except the fleet API's schema types
(`scripts/deps.ts` boundaries + a path-filtered CI job); the test-only exception is packages/web/test/*.contract.test.ts, which may runtime-import the fleet server harness (the WS contract test) — src/ stays type-only.
- `packages/core/src/capabilities.ts` — the capability matrix. ENFORCED rows
must name a proof (test file or spike record) that exists; `bun run docs:check`
and `capabilities.test.ts` fail otherwise.
- GENERATED — never hand-edit: `schemas/*.schema.json`, `docs/SCHEMAS.md`,
`docs/CAPABILITIES.md`, `docs/CONFIG.md`. Edit the Zod schema / capability
table, then run `bun run docs:gen`.
- Hand-maintained: `README.md`, `AGENTS.md`, `SYSTEM_ARCHITECTURE.md`, `docs/DOGFOOD.md`, `spikes/*/RESULTS.md`, `.github/**`.
- Hand-maintained: `README.md`, `AGENTS.md`, `SYSTEM_ARCHITECTURE.md`, `docs/DOGFOOD.md`, `docs/adr/*.md`, `docs/roadmap/*.md`, `spikes/*/RESULTS.md`, `.github/**`.
- One repo, one npm package per surface (`docs/adr/0001-monorepo.md`): core never imports the CLI or the plugin, and the CLI never imports the plugin (`bun run deps:check`).
## Commands
- `bun install` · `bun run check` (biome + tsc + all tests) · `bun run test`
- `bun run docs:gen` / `bun run docs:check` (drift; CI runs the check)
- `scripts/v2-head.sh [tests…]` — real-host suite against OpenCode `v2` HEAD
(repoints node_modules symlinks; `bun install` restores them)
- `es <status|runs|watch|approve|trust|waive|gates|factory|research|brainstorm|harvest|design|config|lsp|hooks|audit|scout|mcp>`
- `es <status|runs|watch|approve|trust|waive|gates|factory|research|brainstorm|harvest|design|config|lsp|hooks|audit|scout|mcp|improve>`
- `es factory run --headless [--driver opencode] [--max-turns N] [--log-level quiet|info|debug] [--events jsonl] [--events-file <path>] [--turn-timeout S] [--cwd <dir>]` (SIGINT/SIGTERM cancel, exit 130; detached HEADs and other runs' seat worktrees refused; launching one stays human-only, spend-bearing like audit/scout runs; `docs/HEADLESS.md`)
- `es mcp` pins caller identity to the adapter environment (`ES_MCP_AGENT`, else legacy `ES_AGENT`): the per-call `agent` argument is ignored; without either the caller is `mcp` (no seat)
- The CLI grammar lives in core (`packages/core/src/util/args.ts`): `parseArgs`, `flag`, `Args`, `BOOLEAN_FLAGS` — the human-only policy reads the same grammar (argv parity, #97)
- One package list (`bun scripts/packages.ts [--release] [--tsconfig] [--browser] [--no-browser]`): the typecheck loop, `pack-smoke.sh`, `publish.yml` and `deps.ts` all read it; new packages declare `esRelease.order` and `BOUNDARIES` (#102); the `bun --conditions=browser` test split comes from each manifest's `esTest.conditions`
- OpenCode slash commands: `/grill /factory /audit /scout /brainstorm /harvest /design /research /gates /status /lsp /hooks /config` plus `es-*` TUI palette commands (panels: `/es-factory`, `/es-lsp-panel`, `/es-hooks`, `/es-brainstorm`; `/es-close`).
## Contract invariants (do not break)
| Invariant | Where |
| :--- | :--- |
| Approvals, waivers, trust and resume are human-only: terminal passphrase confirmation unlocks the sealed Ed25519 human key (signed, hash-bound); the approve/trust/resume RPCs no longer exist and the TUI only previews, then points at the terminal command; agents may request, never grant | `approval/`, `gates/waivers.ts` |
| Approvals are human-only; trust, waivers and resume stay terminal-only: passphrase confirmation (terminal echo-off, TUI masked dialog, or loopback browser input per ADR 0002) unlocks the sealed Ed25519 human key (signed, hash-bound) and every surface signs approvals in-process — the TUI with channel `tui`, the browser through the loopback-only fleet endpoints with channel `web`; the approve/trust/resume RPCs no longer exist; agents may request, never grant | `approval/`, `gates/waivers.ts` |
| Control files are deny-write for all agents: `.factory/{gates.json,config.json,frontier.json,waivers,approvals,runtime}`, the evidence (`.factory/research/{sources,coverage.json,dossier.json,brief.pcrb.json}`, `.factory/claims/`), `.git/config` and git hooks. The pinned ones (gates, config, frontier, approvals, waivers) are also hash-checked at every gate run; cached sources are engine-sealed (HMAC) and content-addressed | `trust/control.ts` |
| Per-seat path scopes: manager writes factory docs, programmer writes its worktree, QA and the auditor pair write nothing, research alpha/beta write only their own notes (`.factory/research/alpha.md` / `beta.md`) and only the factory writes `research/REPORT.md`, brainstorm/harvest/design/scout write only their `.factory/<x>/notes/`; research, scout and auditor seats are `web: "cached"` (host websearch/webfetch denied, web facts only through the cached `es_research_*` tools) and run `--unshare-net` sandboxed, while user agents keep host web tools | `trust/policy.ts`, `agents/registry.ts` |
| Per-seat path scopes: manager writes factory docs, programmer writes its worktree, QA and the auditor pair write nothing, research alpha/beta write only their own notes (`.factory/research/alpha.md` / `beta.md`) and `research/REPORT.md` has one writer per run mode — the factory in build runs, the research synthesizer in research runs (`PolicyContext.runMode`; an unreadable mode denies both), brainstorm/harvest/design/scout write only their `.factory/<x>/notes/`; research, scout and auditor seats are `web: "cached"` (host websearch/webfetch denied, web facts only through the cached `es_research_*` tools) and run `--unshare-net` sandboxed, while user agents keep host web tools | `trust/policy.ts`, `agents/registry.ts` |
| Factory seats launch seats in the foreground only: `subagent` with `background: true` from any seat is refused, and so is launching a seat that is still running; seat state and last activity are recorded in `.factory/runtime/seats.json` (advisory) | `opencode/src/policy.ts`, `opencode/src/seats.ts` |
| Code audits block: an opened phase audit holds `passPhase` (a missing one too with `audit.phase: required`, set via `es config set`), any other open or failed audit refuses the next stage transition; every finding is witnessed against the file on disk; only a human dismisses one | `factory/machine.ts`, `codeaudit/` |
| Scout verdicts are computed by core: `adopt` survives only for a verified, whitelisted, permissive license | `scout/verdict.ts` |
+412
View File
@@ -1,5 +1,417 @@
# Changelog
## 1.6.0 — 2026-10-09
Phase 5 self-improvement: runs already leave rich records (audit chain,
QA failures, gate rejections, findings, spend, evals) and now the factory
learns from them. `es improve harvest` aggregates runs and evals into one
versioned, secret-scrubbed telemetry dataset; `es improve distill` clusters
recurring failures into reviewable proposals a human applies; the
`self-dogfood` preset builds this repo from an issue into a draft PR.
The published trio (core/CLI/plugin) releases in lockstep as 1.6.0.
It is the first npm release since 1.1.4: 1.1.5 through 1.5.0 below were
never published on their own and ship here, together with the fixes from
the 1.0 cutover review (#151, remediated in #152).
**Security.**
- **ADR 0002 I3: the CLI signs only what it previewed.** `es approve`
binds the previewed subject hash and refuses a preview older than
120 s; TUI preview tickets are redeemed once and expire after 120 s.
- **#143: CLI passphrase attempt limiting.** Every CLI human verb shares
the TUI's 5-per-10-minute limit per state dir, with audited lockouts.
- **Human-only verbs.** `es factory run` (a spend-bearing headless run)
and `es-fleet status --token` (the bus bearer token) are refused from
agent shells.
- **#99: no "human" agent name bypass.** Research tools trust a human
operator only when the CLI builds them with `operator: "human"`; an
agent named `human` gets the registry check like any other.
- **#111: one REPORT.md writer per run mode,** read from sealed run
state; when the mode cannot be read, neither seat may write it.
- **SSRF guard resolves DNS.** Every fetch hop, redirects included,
resolves its host and refuses when any address is non-routable (fail
closed); each hop gets its own timeout.
- **#135: secret values are scrubbed,** not just secret-named keys:
`NAME=value` pairs, `Bearer` credentials, known token shapes (dashed
`sk-proj-…` bodies included) and PEM private keys.
- **Control files.** `.factory/improve/**` is deny-write for agents; the
self-dogfood preset writes its control files atomically under lock,
then rebaselines and audits.
- **Fleet.** Request headers parse into a `Map` (CodeQL #84, prototype
pollution); package-boundary gaps closed (web→core and plugin→fleet
imports, inline `type` imports that still load at runtime).
**Behaviour changes.**
- Headless turn metrics print only at `--log-level debug` or to
`--events jsonl`; default output is unchanged from before #120.
- `es-fleet start` asks for confirmation with `--foreground` too; a
background start waits for the daemon's pidfile before reporting.
- A seat whose configured model is missing on the host is refused on
every tool call, primaries included (#96).
**New.**
- **#135: telemetry harvester over runs and evals.** Read-only
`es improve harvest --runs <dir>[,<dir>] [--evals <dir>[,<dir>]] --out <file>`
(agent-safe) producing the versioned `TelemetrySchema` dataset: phases
with replans and failure counts, halts with reasons, spend, gate
rejections by rule, audit findings by kind/severity, eval results with
`modelLimited`, and the audit-chain verification. A broken chain is
reported, never repaired; env-like values are scrubbed and state-dir
paths collapsed. Schemas render to `schemas/` and `docs/SCHEMAS.md`.
- **#136: distillation into reviewable proposals.** Deterministic
clustering keyed on (kind, label) with defaults of 3 occurrences across
2 runs; each surviving cluster becomes one proposal under
`.factory/improve/proposals/<id>/` — `prompt-guidance` (a version-bumped
patch that passes `git apply --check` and the assets drift check),
`gate-tuning` (an explanation only; `gates.json` stays a human-applied
control file) or `domain-pack` (validated against `DomainPackSchema`).
The "code disposes" gate drops proposals whose evidence is not a
verbatim harvester quote or whose model numerals the telemetry never
recorded. `es improve distill --telemetry <file> --out <dir>` is
agent-safe; `--open-pr` is human-run (refused without a terminal) and
drafts from a topic branch without pushing the base.
- **#137: self-dogfood preset.** `es factory init --preset self-dogfood
--issue <n>` (human-only) seeds `.factory/` from versioned preset
assets — project config (`audit.phase: required`), the real merge gate
(`bun install --frozen-lockfile`, `bun run check`, `bun run docs:check`),
a roadmap seed pinning `baseBranch: rewrite`, and the issue as the idea
note — then reminds about `es trust`, the $15 spend ceiling and the
frontier/spec approvals. `docs/SELF-DOGFOOD.md` is the runbook
(prerequisites, flow, review, abort); one human-run validation session
to a draft PR is tracked on #137.
- **#138: release 1.6.0.** Version bump, changelog and status line; draft
release PR from `integration/phase-5` to `rewrite`.
**Fixes.**
- **#122:** a plain `es-fleet start` now daemonizes; the detached child
used to fail the TTY check while the parent printed "starting".
- **#147:** a failed landing merge aborts only a real conflict and keeps
the original error otherwise.
- **Fleet worktrees:** `gc` can no longer remove a worktree mid-allocation,
and a second allocator of the same task refuses instead of dropping the
first one's record.
- **#137:** a preset run's release base is pinned end to end (the grill
cannot drop it, and the PR opener refuses `--base main`).
- **#120:** tool metrics carry `ms` and `seat`.
- **#130:** the web task page shows run liveness (headline, stage, seats,
research progress).
- **#136:** clusters key on (seat, failure kind, rule or category) with
evidence links; dropped proposals are reported with their reason.
- **#109:** the darkharvest grader reads the run the case produced;
loopback is allowed only for fixture-server eval cases.
- **#111:** `es research deep` and `/research deep` no longer send the
deep-researcher prompt twice.
## 1.5.0 — 2026-10-09
Phase 4 web control plane: the fleet daemon serves a browser UI on
loopback behind a one-time URL — a fleet dashboard, frontier/spec
approvals in the browser (ADR 0002 option b), a preview-only config
editor, and an evidence explorer with range-highlighted quotes and seal
statuses. `packages/web` stays private and is served from the repo (the
fleet daemon is private too, so no asset-shipping decision is needed);
the published trio (core/CLI/plugin) release in lockstep as 1.5.0.
**Security.**
- **#129: the web UI is localhost-only behind a single-use ticket
exchange.** `es-fleet web` (human-only, refused under `ES_SANDBOX`)
mints a 32-byte ticket (TTL 120 s, atomic-rename claim) that the first
load redeems for an `HttpOnly; SameSite=Strict` session cookie; wrong
Host (DNS-rebinding defense) and non-loopback Origin are refused, and
every web response carries a strict CSP (`default-src 'self'`, no
inline scripts, `frame-ancestors 'none'`).
- **#131: browser approvals sign only what was previewed.** Preview
returns the subject, files and hashes with a single-use ticket bound
to `hashJson(subject)`; submit needs the session cookie, a loopback
Origin and the session CSRF token, then re-derives the subject
(409 on mismatch), unlocks the sealed key in-process, records with
channel `web`, and destroys the signer. Five wrong passphrases lock
the web surface with an audited `approval.lockout`; the passphrase
lives in one page-local variable and appears in no log, event,
response or state file (probed). The ADR 0002 adversarial-review
checklist for the browser path passes 9/9 applicable probes (posted
on #131); I8 stays user-confirmable (option b implemented).
Trust, waive, resume, key seal, config set and export are untouched
and stay terminal-only.
- **#133: hostile source text is inert.** Quotes render through text
nodes split by verified ranges into `<mark>` spans (never HTML
parsing, probed with `<script>`/event-handler payloads); seal status
(sealed/unsealed/invalid/unknown) shows on every source, with
tampered bytes refused by their content hash.
**Behaviour changes.**
- `fleet.status` pending entries now carry the approvable `stage`,
enriched from each worktree's pending list (#131); snapshot tasks
carry `title` and `deps` for the DAG (#130).
- `bun run test` runs the web suite first under browser conditions
(the client Solid build), then every other package (#130).
**New.**
- **#129: `packages/web` (private, never published).** SolidJS + Vite
(ADR 0003: shared signals with the TUI, 15 kB hello build), served by
the daemon from the bus port; declared `deps.ts` boundaries and a
path-filtered CI job.
- **#130: fleet dashboard.** Columns-by-depth task DAG, task table,
spend against the ceiling, pending list, per-task pages and a live WS
stream with reconnect refetch and a 5 s stale banner — read-only
throughout, status always in words beside colour.
- **#131: approvals pages.** `#/approvals` list and
`#/approve/<task>/<stage>` page (masked, non-reactive input cleared
on submit; remaining-attempt and lockout-delay alerts).
- **#132: config editor, preview-only.** Project/gates tabs with
strict-schema validation, exact hash diffs and copyable terminal
commands over read-only `fleet.config.get`/`fleet.config.plan`; the
bus has no apply method and the UI no apply button. Browser apply is
deferred to #145 (needs #131's pattern plus the user's ADR 0002
extension).
- **#133: evidence explorer.** Claim/source graph with tag/status/tier
filters, inspector (rank explanation, retractions, history), source
viewer with verified quote highlights, and Markdown/HTML dossier
exports through the #112 renderers — read-only, capped payloads.
Core gains `locateQuote` (index-mapped ranges, self-checked).
**Evals and CI.**
- Web tests render with the client Solid build under happy-dom
(`bun --conditions=browser test packages/web`).
- Full `bun run test`: the same 8 pre-existing bwrap-overlay sandbox
failures as 1.4.0 (identical set fails on the base; green in CI
where bwrap works).
**Dependencies.**
- `packages/web` shares the TUI's `solid-js` 1.9.15 lockfile entry; new
stock npm needs are `vite`, `vite-plugin-solid` and dev-only
`happy-dom`. Zero vendored code in the phase.
## 1.4.0 — 2026-10-09
Phase 3 fleet: concurrent task DAGs run on one machine under a human-only
`es-fleet` daemon — a deterministic scheduler, isolated git worktrees per
task with gated landings onto an integration branch, one headless run per
task with per-task spend ceilings, and a read-only localhost telemetry bus
with an `es-fleet watch` dashboard. `@heretek-ai/es-fleet` ships from the
repo only (private) until its RPC is stable; the published trio
(core/CLI/plugin) release in lockstep as 1.4.0.
**Security.**
- **#122: starting or stopping the fleet is human-only, by policy and in
the binary.** The policy's binary matcher knows `es-fleet`, and the whole
binary is human-only except `status`/`--help` (fail-closed flag
resolution); `start` additionally refuses agent sandboxes, requires a TTY
plus a typed confirmation, and takes a mandatory fleet-wide spend ceiling.
Fleet state lives under the sandbox-masked state dir.
- **#124: task worktrees are isolated by policy.** `.fleet/` is deny-write
for every agent outside its own task worktree, and seats may not name
other tasks' `.fleet/` paths in the shell. The base branch is never
checked out, reset or merged — asserted in every worktree test.
- **#126: the telemetry bus is read-only, localhost-only and
token-authenticated.** Host allowlist (DNS-rebinding defense), Origin
checks on WebSocket upgrades, strict schemas that reject extra fields,
and payload scrubbing (secret keys and known secret values never appear).
**Behaviour changes.**
- `es-fleet` refuses to start without a positive `--max-usd` ceiling, which
caps the summed task ceilings.
- `es factory run --headless` takes no `--max-usd` (verified): workers
provision each task's ceiling into its run state, where the factory guard
enforces it.
- A landing conflict reopens the task as `waiting-human` — the one
transition allowed out of `done` — and the daemon retries it after human
repair.
**New.**
- **#122: `packages/fleet` (`@heretek-ai/es-fleet`, private).** State
helpers, inode-bound socket guard, pidfile lifecycle with stale recovery,
`es-fleet start/stop/status`, boundaries in `scripts/deps.ts` and a
path-filtered CI job.
- **#123: task model and deterministic DAG scheduler.** `all_success` /
`one_success` triggers, mandatory ceilings, `fail` / `retry(n)` /
`replan` policies, `waiting-human` capacity relief, pure `schedule()`
with a property test over random DAGs, `es-fleet task add/list/cancel`.
- **#124: worktree coordinator and gated integration branch.**
Two-phase allocation, salvage refs, orphan `gc`, transactional landing
(gates first, `--no-ff` merge in a throwaway worktree, verified rollback
on conflict) onto `fleet/integration/<dag>`.
- **#125: workers.** Argv-only headless runs, event folding with spend and
token tracking, no-double-start supervision with restart recovery,
StillDown + mass-death circuit breaker, and the daemon tick loop
(`schedule → allocate → launch → land → release`) with a SIGTERM kill
switch recording resumable cancellations.
- **#126: telemetry bus and `es-fleet watch`.** Strict-schema RPC
(`fleet.status/task/pending/events` with monotonic backlog replay),
coalesced WebSocket stream, 0600 token, TTY dashboard.
- **#127: merge-blocking fleet integration suite.** A→B+C on the real host
with the fake model proving ordering, overlap, output ownership (and no
`.factory/` leakage across branches), failure/retry/cancel, conflict
repair and stop/salvage/gc — plus break probes proving the suite bites.
**Evals and CI.**
- #127 runs in `bun run check` (~10 s, 5/5 deterministic locally).
- Full `bun run test`: 8 failures are the pre-existing bwrap-overlay
sandbox failures of containers (identical set fails on the base; green in
CI where bwrap works).
## 1.3.0 — 2026-10-09
Phase 2 approvals everywhere: frontier and spec approvals complete in the
OpenCode TUI as well as the CLI, through one shared core service, under
the threat model and invariants of ADR 0002. The headless runner gains
the fleet entrypoint: a versioned JSONL event stream with per-turn
metrics, graceful cancellation and turn timeouts, and worktree validation.
**Security.**
- **#116: ADR 0002 (proposed) puts approvals on every surface.**
Threat model (5 actors), normative invariants I1–I8, a 25-row inventory
of terminal-only enforcement points, and the adversarial-review checklist
gating the TUI (#119) and browser (#131) builds. Trust, waive, resume,
key seal, config set and export stay terminal-only.
- **#119: frontier and spec approvals complete in the OpenCode TUI.**
A masked passphrase dialog (plaintext only in a closure-scoped buffer,
zeroed on submit/cancel) unlocks the sealed human key and signs
in-process through the core `approveStage` service (`channel: "tui"`).
No passphrase and no approval cross RPC (no approve/trust/resume RPC
exists); previews bind the signed subject through a single-use ticket
(TTL ≤ 120 s); 5 wrong passphrases per 10 minutes lock the TUI surface
out with an audited lockout. The masked-input spike (#118) verdict is GO
with headless-renderer evidence.
- **#117: one approval code path in core.** `approveStage` re-derives the
subject with a preview-hash check, signs, clears pending and begins
research under its locks; re-approval of an unchanged subject is a
side-effect-ensuring no-op. `HumanSigner.destroy()` ends signing.
`ChannelSchema` is now `cli | tui | web`, shared by approvals and waivers.
**Behaviour changes.**
- Approval status messages name `/es-approve` first; the TUI pending panel
and the liveness lines point at the TUI with the terminal as the fallback.
- `schemas/approval-channel.schema.json` is replaced by
`schemas/channel.schema.json` (same shape, plus `web`).
- Headless runs without the new flags print as before, plus a
`turn-metrics` line per turn at info level and above.
**New.**
- **#120: fleet-ready headless runner.** `--events jsonl` (versioned
envelope per line: `JsonlEnvelope`/`HeadlessJsonlSchema` exported from
the CLI; `--events-file` redirects to a file), per-turn `turn-metrics`
(sealed spend delta, tool activity, opportunistic tokens),
SIGINT/SIGTERM cancellation (exit 130, resumable), `--turn-timeout`,
and `--cwd` validation (other runs' seat worktrees and detached HEADs
refused; the root is indexed). See `docs/HEADLESS.md`.
## 1.2.0 — 2026-10-09
Phase 1 epistemic layer: research, brainstorm and harvest work outside the
fixed factory flow. Backends fail over with the gateway leading, the grill
and the factory fan out brainstorms and verdict-check harvest targets
themselves, deep research runs thesis/antithesis/synthesis to a grounded
report anywhere, dossiers render to Markdown and HTML, and domain-pack tiers
weight the score. A merge-blocking research-fires suite pins the whole loop.
**Security.**
- **#106: provider API keys are masked in seat sandboxes.**
`BRAVE_API_KEY` and `FIRECRAWL_API_KEY` join `SECRET_ENV`, so agent shells
never see them; backends run in the plugin process.
- **#107: the gateway token never leaves its header.** It comes only from
`ES_SCRAPER_SWARM_TOKEN`, is masked in sandboxes, and never reaches
`.factory/`, logs or error messages.
**Behaviour changes.**
- Research completion in research-mode runs ends at DONE (build runs still
move to SPEC); deferred frontier facts are required only in build runs.
- `FETCHED` snapshots are capability-derived (fetch-capable backends count);
legacy `fetch`/`webfetch` entries still do.
- Prior-art records are keyed per brainstorm run; legacy global records
satisfy any run.
- Brainstorm and harvest artifacts live under `runs/<run>/` (the human's
flows stay the `default` run); legacy top-level files still read.
**New.**
- **#106: ordered source-backend failover** (`SourceBackend`,
`research.backends`, per-backend cooldowns with 429 Retry-After;
`blocked` safety refusals never fail over; `searchProvider` kept as an
alias).
- **#107: Scraper-Swarm gateway backend** (JSON-RPC `web_search`/`fetch_page`
over `POST /mcp`, contract v1 with `content_sha256` verification, leading
the default order when configured; recorded-fixture contract tests).
- **#108: callable brainstorm fan-out** (run-scoped store, grill/factory
callers at depth 1, shortlist JSON, lean budgets).
- **#109: callable harvest target and shared prior art**
(`es_harvest_target` verdicts for grill/factory/scout/harvester,
run-scoped harvest plans, `PriorArtSearchSchema`).
- **#110: research-only runs** (`beginResearchRun`, objective + ceiling, no
frontier/approvals/git needed).
- **#111: deep-research coordinator** (`deep-researcher` + synthesizer seats,
human-only `es research deep`, interactive `/research deep`).
- **#112: dossier renders** (deterministic Markdown + self-contained HTML,
XSS-tested; agent-safe `es research render`).
- **#113: domain-pack tiers reach claims** (deterministic source
classification sealed in `SourceMeta`, `ClaimSchema.tier`, tier-weighted
scores; unknowns stay `__default__`).
**Evals and CI.**
- **#114: merge-blocking research-fires suite** (grounded quotes, failover,
sealing, degradation, renders; break-probe verified) plus a
reported-never-blocking `deep-research` eval case.
## 1.1.5 — 2026-10-09
Phase 0 hardening: every trust boundary the 1.1.x series left soft is now
sealed, pinned or tested — source-cache seals on every reader, MCP caller
identity, CLI/policy argv parity, a registry-generated scoping suite,
fail-closed seat models, and consistency cleanup. No human-only verb changed.
**Security.**
- **#98: every source-cache reader verifies seals.** Scout advisories,
brief export, retract and audit dossiers went through unsealed caches, so
planted entries read as evidence. All constructions now go through the
sealed constructor, planted entries are refused, and a misuse-guard test
fails any new unsealed construction.
- **#99: MCP caller identity is pinned, research tools gain core seat
checks.** A piped `agent` argument could claim any seat over `es mcp`;
identity now comes from the adapter environment (`ES_MCP_AGENT`, else
legacy `ES_AGENT`), defaulting to seat-less `mcp`. `es_research_search`,
`es_research_fetch` and `es_research_audit` enforce the registry grants in
core. The research tools are not on the MCP surface at all.
**Behaviour changes.**
- **#96: seat models fail closed.** Malformed refs and unknown
`models.agents` keys are rejected at config load; a configured model
missing on the host refuses that seat at launch with remediation (naming
the key and `es config set`) and shows in `es_status`. Unset tiers still
inherit the session default, explicitly.
- **#101: `es_request_approval` admits factory and grill only.** The manager
seat was accepted against the registry's grants.
- **#97 precision:** known boolean flags read single-way in the human-only
rule, cutting false denials with zero lost denials (property-gated).
**New.**
- **#100: registry-generated permission matrix and bypass probes**
(`packages/opencode/test/scoping.test.ts`). OpenCode's
`Permission.evaluate` + `Wildcard.match` (MIT) are vendored into the
testkit; every seat's compiled rules are asserted against its registry
spec, and each restriction class is probed on the real host, including the
post-#99 MCP pin.
- **#102: one workspace package list** (`bun scripts/packages.ts`):
the typecheck loop, `pack-smoke.sh`, `publish.yml` and `deps.ts` read it;
new packages declare `esRelease.order` and `BOUNDARIES`.
**Fixes.**
- **#101 registry consistency:** factory tool union equals `ALL_ES_TOOLS`
with single registration (tested both halves); `confirmationCode` and the
ignored `recordApproval` `stateDir` removed; waivers comment corrected to
Ed25519; subagent `spawns` stay empty (depth 1, tested).
- **#139: the `auditor-thesis` eval stages an open audit run**, graded
deterministically on the verdict record instead of narration luck.
- **#103 hygiene:** `.claude/CLAUDE.md` is a pointer to AGENTS.md (dead
`runner/` paths gone); README and DOGFOOD gain the 0.7 options note.
**Evals and CI.**
- Seeded argv-parity property (3 × 2,000 cases) plus HELP-marker parity,
both with recorded break probes (#97).
- `docs/CONFIG.md` documents the nested `models.*` keys (#96).
- CI pattern for future packages documented in ADR 0001 (path-filtered
workflows; `check` stays unfiltered) (#102).
**Dependencies.** `@opentui/core` + `@opentui/solid` 0.5.14 → 0.5.17: the
nested `@babel/core` 7.28.0 pin is gone (7.29.7).
## 1.1.4 — 2026-10-08
A security fix for the human-only shell rule, plus the E2E feedback fixes
+82 -21
View File
@@ -5,17 +5,13 @@
> phases under dual QA, and open a PR a human merges. Mechanical gates and a
> human-only approval channel keep autonomous runs honest.
> **Status: 1.1.4.** A security fix: a flag before the verb
> (`es --cwd . <verb>`) no longer slips past the human-only shell rule
> (#88). 1.1.3 added live-run visibility and seat discipline. Every status
> view leads with whether the run is working, waiting, stuck or done:
> `es status`, `es watch`, `es runs`, the factory dashboard and a TUI footer
> indicator. Factory seats run in the foreground, with one writer per
> research file. 1.1.2 added `audit.phase`, pipeline A and
> signed run state; 1.1.1 was the security release (bubblewrap for every agent
> shell, the passphrase-sealed Ed25519 human key, terminal-only approvals).
> Install from npm (`@heretek-ai/epistemic-swarm`, `@heretek-ai/es-core`,
> `@heretek-ai/es-cli`); run `es key seal` once.
> **Status: 1.6.0.** Phase 5 self-improvement: telemetry harvest
> (`es improve harvest`), distillation into reviewable proposals
> (`es improve distill`), and the self-dogfood factory preset
> (`docs/SELF-DOGFOOD.md`) — runs learn from runs, and the factory builds
> this repo into draft PRs a human reviews (#135–#138). Install from npm
> (`@heretek-ai/epistemic-swarm`, `@heretek-ai/es-core`, `@heretek-ai/es-cli`);
> run `es key seal` once.
## Naming
@@ -28,11 +24,14 @@
- **Factory stages, enforced by code.** Grill → Research → Spec → Build ⇄ QA →
Release runs through a state machine; every `es_*` tool refuses an
out-of-order step. Approvals (frontier, spec), waivers and trust are human-only,
signed with the passphrase-sealed Ed25519 human key at a terminal
(`es key seal` once, then `es approve` / `es trust` / `es waive`); agents
out-of-order step. Approvals (frontier, spec) are human-only and complete
in the TUI (masked passphrase dialog, in-process signing), in the browser
(preview, then passphrase over loopback), or at a terminal
(`es approve`); waivers and trust stay terminal-only (`es waive`,
`es trust`), all signed with the passphrase-sealed Ed25519 human key
(`es key seal` once); agents
may request, never grant. The approve/trust/resume RPCs no longer exist:
the TUI previews, then points at the terminal command.
the TUI signs approvals itself and previews trust/resume for the terminal.
- **Mechanical gates.** Format, lint and typecheck on touched files, affected
tests from a tree-sitter import graph (runner-native fallback), secret
scanning, OSV, budgets (diff size, file length, complexity, dependency
@@ -40,16 +39,54 @@
findings and signed, expiring waivers.
- **Evidence-first research.** Content-addressed source cache, a pure verbatim
quote verifier, and an epistemic auditor that downgrades ungrounded claims.
Web sources come through an ordered backend chain (Scraper-Swarm gateway,
Brave, Firecrawl, SearXNG, direct fetch) with per-backend cooldowns: one
provider's outage or rate limit fails over to the next, while a safety
refusal never does. To use the self-hosted gateway, mint a key with scopes
`search` and `scrape` (`POST /agents/keys` on the panel API) and set
`ES_SCRAPER_SWARM_URL` and `ES_SCRAPER_SWARM_TOKEN`; the token never leaves
its `Authorization` header and is masked in seat sandboxes. Deep research
runs a question adversarially outside the factory flow — thesis, antithesis,
synthesis to a grounded report (`es research deep "<question>" --output
<dir> --max-usd N`, or `/research deep` in the TUI). A run renders to
readable Markdown or a self-contained HTML dossier — tag, status, verbatim
quote, source and seal per claim (`es research render --format md|html`).
The signed brief stays human-only (`es research export`).
- **Domain packs.** Pluggable constitutions (quant, biopharma, legal) vet
research claims: banned domains, mandatory tags, retraction policy and an
accept threshold on the tier-weighted epistemic score. Every cached source
is deterministically tiered at ingestion (preprint, peer-reviewed, docs,
press — sealed with its metadata) and claims inherit the tier, so source
quality moves the score.
- **Lateral work.** Brainstorm fans out eight divergent lenses into a
deduplicated, rubric-scored shortlist with a forced outlier; darkharvest
deduplicated, rubric-scored shortlist with a forced outlier — callable by
the grill and the factory at depth 1, with the shortlist back as JSON; darkharvest
tears down competitor projects with fail-closed SPDX detection, per-field
provenance and clean-room specs; queereye interviews you into a
provenance and clean-room specs, plus a callable verdict check for the
grill, the factory and the scout; queereye interviews you into a
contrast-gated DTCG token system with a generated style guide.
- **Self-improvement.** `es improve harvest` aggregates runs and evals into
one versioned, secret-scrubbed telemetry dataset (read-only; a broken
audit chain is reported, never repaired), and `es improve distill`
clusters recurring failures into reviewable proposals — prompt guidance
(a version-bumped patch), gate tuning (an explanation only; gates.json
stays a human-applied control file) and domain-pack candidates
(schema-validated). A "code disposes" gate drops proposals whose
evidence is not verbatim or whose model numerals the telemetry never
recorded; nothing is applied automatically, and `--open-pr` (human-run)
puts the proposals on a topic branch as a draft PR. The `self-dogfood`
factory preset builds this repo from an issue into a draft PR against
`rewrite` (see `docs/SELF-DOGFOOD.md`).
- **Live-run visibility.** `es status`, `es watch` and the factory dashboard
lead with one plain sentence (working, waiting on you, possibly stuck,
halted or done), then each seat's state and last activity and the research
progress. `es runs` lists runs across projects. The TUI footer shows the
stage and the running seat; headless runs emit `progress` events.
`es-fleet web` opens the same fleet in a browser: dashboard, browser
approvals, a preview-only config editor, and the evidence explorer
(claim graph, range-highlighted quotes, seal status, dossier exports).
`es-fleet` is private, never published to npm — run it from a checkout
(`bun packages/fleet/bin/es-fleet.js …`).
- **OpenCode v2 native.** One plugin registers agents, tools, commands, the
hook bridge, the LSP runtime and four TUI panels — additively, with no files
written. Host web results are cached (citable by hash) only for factory
@@ -64,6 +101,8 @@
| `packages/opencode` | The OpenCode v2 plugin (`server` and `tui` entrypoints). |
| `packages/cli` | The `es` CLI and a coarse MCP server for non-OpenCode harnesses. |
| `packages/testkit` | Real in-process host testing (`boot`, scripted fake model). |
| `packages/fleet` | The `es-fleet` daemon (private): concurrent task DAGs in isolated worktrees. |
| `packages/web` | The web control plane (private, never published): dashboard, browser approvals, config editor, evidence explorer — SolidJS, served by `es-fleet` on loopback. |
| `scripts/` | `docs.ts` (generated contracts/docs) and `v2-head.sh` (nightly compatibility). |
| `schemas/`, `docs/` | Generated: JSON Schemas, capability matrix, config and schema docs. |
| `spikes/` | Recorded proofs from the M0/M6 spikes. |
@@ -98,10 +137,32 @@ rather than silently assumed:
(bounded by `ES_EVAL_MAX_STEPS`) or once the run's spend, read from the
stream's `step_finish` cost, passes `ES_EVAL_MAX_USD`. A model with no
configured price reports $0, so for it only the step caps bound spend.
- **MCP caller identity.** Over the stdio MCP server the calling agent's
identity is a model-supplied `agent` argument (or `ES_AGENT`), so it is
ADVISORY: the harness adapter, not the protocol, establishes it. There is no
MCP tool for approvals, trust, waivers or resume.
- **MCP caller identity is pinned.** Over the stdio MCP server the calling agent's
identity comes from the adapter's environment (`ES_MCP_AGENT`, else the legacy
`ES_AGENT` default) — one server per agent, set in the human-written adapter
config — and the model-supplied `agent` argument is ignored. Without an adapter
identity the caller is `mcp`, which maps to no seat, so every seat-checked tool
refuses. There is no MCP tool for approvals, trust, waivers or resume.
## Upgrading from 0.7
The 0.7-era plugin options `search_engine`, `max_iterations` and `mode` were
removed in 1.0. A config that still carries them loads, but warns once:
`Ignored unknown plugin options: …`. Delete those keys; a clean `plugins`
entry only needs `models` per tier:
```json
{
"package": "@heretek-ai/epistemic-swarm",
"options": {
"models": {
"deep": "<provider>/<model>",
"balanced": "<provider>/<model>",
"fast": "<provider>/<model>"
}
}
}
```
## Development
+33 -7
View File
@@ -32,6 +32,14 @@ the run halts. `.factory/STOP` halts every seat tool (except `es_status` when
halted by spend/runtime); resuming is human-only. The spend ceiling is
mandatory and raising it is a human act.
**Research-only runs** (`mode: "research"`) answer one objective outside the
software flow: `beginResearchRun({objective, ceilingUSD})` enters RESEARCH
directly with no frontier, no approvals and no git repo required, and
`completeResearch` ends at DONE (deferred frontier facts are not required;
the dossier takes the run's objective). Spend tracking, STOP, halts, liveness,
`es status`/`es watch`/`es runs` and the sealed evidence cache are the same
machinery. The completing seat is the research coordinator (#111).
## 2. Seats
Seats are what the trust policy reasons about; the registry is
@@ -81,18 +89,29 @@ domain packs (quant · biopharma · legal) ──▶ banned domains, mandatory t
HMAC-SHA256 seal from a key kept in the masked private state dir
(`engine.key`), so a planted entry an agent drops into
`.factory/research/sources/` is refused on read.
- Sources arrive through an ordered backend chain (`research.backends`,
default Scraper-Swarm gateway → Brave → Firecrawl → SearXNG → direct; `searchProvider` still works
as a one-element alias). Failures classify as unavailable, auth,
rate-limited (429 Retry-After sets the cooldown), upstream or timeout and
fail over to the next backend, which is recorded in `SourceMeta.provider`;
a `blocked` safety refusal stops the chain instead of routing around it.
- The design tree (`frontier.json`) is the same shape for humans: nodes are
decisions or deferred facts, saves are diff-checked (no deletions, no silent
edits, no round regress), and the tree is the single source for the idea.
## 4. Trust model (non-negotiable invariants)
1. **Approvals, waivers, trust and resume are human-only** — terminal
passphrase confirmation unlocks the passphrase-sealed Ed25519 human key
1. **Approvals are human-only; trust, waivers and resume stay terminal-only**
— passphrase confirmation (terminal echo-off, TUI masked dialog, or
loopback browser input per ADR 0002) unlocks the passphrase-sealed
Ed25519 human key
(scrypt + AES-256-GCM; `es key seal` once, `es key status` shows the
fingerprint), which signs the record; agents may request
(`es_request_approval`) but never grant. The approve/trust/resume RPCs no
longer exist; the TUI previews, then points at the terminal command. v1
longer exist; approvals complete in the TUI (masked dialog, in-process
signing, channel `tui`), in the browser (preview ticket bound to the
subject hash, passphrase over loopback, channel `web`) as well as the CLI, while trust
and resume preview in the TUI and sign at the terminal. v1
HMAC records are refused and must be re-recorded.
2. **Control files are deny-write for every agent**: gates, config, frontier,
approvals, waivers, runtime state, the research evidence, claim ledger,
@@ -120,7 +139,10 @@ domain packs (quant · biopharma · legal) ──▶ banned domains, mandatory t
a signed sidecar (HMAC under the masked engine key); reads refuse a
missing or forged seal, and only a human re-signs reviewed files
(`es reseal --sign`, terminal passphrase confirm; agents have no reseal
path — no tool, shell verb denied). Verifiers never create the engine key:
path — no tool, shell verb denied). Every source-cache reader verifies
seals: scout witnessing, brief export and retract all read through the
sealed constructor, so unsealed legacy entries are refused until the
human re-fetches the source. Verifiers never create the engine key:
a reader that cannot see it (an agent sandbox, another state dir) reports
a missing key, not a forged seal.
9. **Seats run in the foreground, one writer per research file.** A seat's
@@ -134,10 +156,12 @@ domain packs (quant · biopharma · legal) ──▶ banned domains, mandatory t
## 5. Evaluations
- **Merge-blocking (deterministic):** five fire suites on the real in-process
- **Merge-blocking (deterministic):** six fire suites on the real in-process
host — `grill-fires`, `factory-gate`, `darkharvest-fires`, `audit-fires`,
`scout-fires` (`packages/opencode/test/fires*.test.ts`), plus the drift
canary (`packages/core/test/drift.test.ts`).
`scout-fires`, `research-fires` (`packages/opencode/test/fires*.test.ts`),
plus the drift canary (`packages/core/test/drift.test.ts`), plus the fleet
integration suite (`packages/fleet/test/integration.test.ts`: a task DAG on
the real host with the fake model proving concurrency without interference).
- **Nightly (model-backed, cost-capped):** `evals/cases/*.json` run against a
real model with the same graders (`bun run evals`).
- **Capability matrix:** every ENFORCED row names a test or spike that exists;
@@ -155,6 +179,8 @@ domain packs (quant · biopharma · legal) ──▶ banned domains, mandatory t
| `packages/opencode/src/` | plugin: agents, policy, tools, commands, panels |
| `packages/cli/src/` | `es` CLI, human-only commands, headless jobs |
| `packages/testkit/` | real-host `boot`, scripted fake model |
| `packages/fleet/` | `es-fleet` daemon: task DAGs, isolated worktrees, per-task ceilings (private) |
| `packages/web/` | the web control plane (private, never published): dashboard, browser approvals, config editor, evidence explorer — SolidJS, served by `es-fleet` on loopback |
The queereye designer domain is `packages/core/src/queereye/`; the
`.factory/design/` tree holds `interview.json`, `tokens.*`, `probes.json`,
+221 -23
View File
@@ -14,18 +14,19 @@
},
"packages/cli": {
"name": "@heretek-ai/es-cli",
"version": "1.1.4",
"version": "1.6.0",
"bin": {
"es-factory": "./dist/bin.js",
"iumbtems": "./dist/bin.js",
"es": "./bin/es.js",
"epistemic-swarm": "./bin/es.js",
},
"dependencies": {
"@heretek-ai/es-core": "workspace:*",
"zod": "4.6.5",
},
},
"packages/core": {
"name": "@heretek-ai/es-core",
"version": "1.1.4",
"version": "1.6.0",
"dependencies": {
"vscode-jsonrpc": "9.0.3",
"vscode-languageserver-protocol": "3.18.4",
@@ -33,9 +34,23 @@
"zod": "4.6.5",
},
},
"packages/fleet": {
"name": "@heretek-ai/es-fleet",
"version": "0.1.0",
"bin": {
"es-fleet": "./bin/es-fleet.js",
},
"dependencies": {
"@heretek-ai/es-core": "workspace:*",
"zod": "4.6.5",
},
"devDependencies": {
"@heretek-ai/es-testkit": "workspace:*",
},
},
"packages/opencode": {
"name": "@heretek-ai/epistemic-swarm",
"version": "1.1.4",
"version": "1.6.0",
"dependencies": {
"@heretek-ai/es-core": "workspace:*",
"@opencode/plugin": "2.0.24",
@@ -45,8 +60,8 @@
"@babel/preset-typescript": "7.27.1",
"@heretek-ai/es-testkit": "workspace:*",
"@opencode/sdk": "2.0.24",
"@opentui/core": "0.5.14",
"@opentui/solid": "0.5.14",
"@opentui/core": "0.5.17",
"@opentui/solid": "0.5.17",
"babel-preset-solid": "1.9.12",
"solid-js": "1.9.15",
},
@@ -68,6 +83,19 @@
"@opencode/sdk": "2.0.24",
},
},
"packages/web": {
"name": "@heretek-ai/es-web",
"version": "0.1.0",
"dependencies": {
"@heretek-ai/es-fleet": "workspace:*",
"solid-js": "1.9.15",
},
"devDependencies": {
"happy-dom": "20.14.6",
"vite": "7.3.6",
"vite-plugin-solid": "2.11.14",
},
},
},
"packages": {
"@ai-sdk/cohere": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OqcCq2PiFY1dbK/0Ck45KuvE8jfdxRuuAE9Y5w46dAk6U+9vPOeg1CDcmR+ncqmrYrhRl3nmyDttyDahyjCzAw=="],
@@ -82,8 +110,6 @@
"@ai-sdk/vercel": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
"@ampproject/remapping": ["@ampproject/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/[email protected]", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="],
"@anycable/core": ["@anycable/[email protected]", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="],
@@ -218,6 +244,58 @@
"@effect/platform-node-shared": ["@effect/[email protected]", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ=="],
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
"@ff-labs/fff-bin-android-arm64": ["@ff-labs/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-idn1m9ycwv0Dzz6ih4H8e/X1WpGF6kglUxqP78oYgoC2sgAdPtLxD6hIAZK13p8isK7H8EMwrMze/4lynEDcKQ=="],
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tG0VSt6fs3C2/Y6NDp57YJVXGEnrJ4F/e5gDrFCbNeuwJe9LmcCKh6oHWUEZOaaymYT90kQmwuXZTvORP3G6Vw=="],
@@ -250,8 +328,12 @@
"@heretek-ai/es-core": ["@heretek-ai/es-core@workspace:packages/core"],
"@heretek-ai/es-fleet": ["@heretek-ai/es-fleet@workspace:packages/fleet"],
"@heretek-ai/es-testkit": ["@heretek-ai/es-testkit@workspace:packages/testkit"],
"@heretek-ai/es-web": ["@heretek-ai/es-web@workspace:packages/web"],
"@isaacs/cliui": ["@isaacs/[email protected]", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/fs-minipass": ["@isaacs/[email protected]", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
@@ -298,6 +380,8 @@
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@npmcli/agent": ["@npmcli/[email protected]", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="],
"@npmcli/arborist": ["@npmcli/[email protected]", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^5.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/map-workspaces": "^5.0.0", "@npmcli/metavuln-calculator": "^9.0.2", "@npmcli/name-from-folder": "^4.0.0", "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/query": "^5.0.0", "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.0", "bin-links": "^6.0.0", "cacache": "^20.0.1", "common-ancestor-path": "^2.0.0", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", "minimatch": "^10.0.3", "nopt": "^9.0.0", "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.0", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "pacote": "^21.0.2", "parse-conflict-json": "^5.0.1", "proc-log": "^6.0.0", "proggy": "^4.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "semver": "^7.3.7", "ssri": "^13.0.0", "treeverse": "^3.0.0", "walk-up-path": "^4.0.0" }, "bin": { "arborist": "bin/index.js" } }, "sha512-4Bm8hNixJG/sii1PMnag0V9i/sGOX9VRzFrUiZMSBJpGlLR38f+Btl85d07G9GL56xO0l0OZjvrGNYsDYp0xKA=="],
@@ -392,25 +476,25 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/[email protected]", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/[email protected]4", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.14", "@opentui/core-darwin-x64": "0.5.14", "@opentui/core-linux-arm64": "0.5.14", "@opentui/core-linux-arm64-musl": "0.5.14", "@opentui/core-linux-x64": "0.5.14", "@opentui/core-linux-x64-musl": "0.5.14", "@opentui/core-win32-arm64": "0.5.14", "@opentui/core-win32-x64": "0.5.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-tfQ+PWQyeBnYloB3diEcPqbILv14xemH5jjAEICfPuyNDtGBqrjhUtThrhbvFRuPMcj6IEeXrAk6VE8e91h0kg=="],
"@opentui/core": ["@opentui/[email protected]7", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.17", "@opentui/core-darwin-x64": "0.5.17", "@opentui/core-linux-arm64": "0.5.17", "@opentui/core-linux-arm64-musl": "0.5.17", "@opentui/core-linux-x64": "0.5.17", "@opentui/core-linux-x64-musl": "0.5.17", "@opentui/core-win32-arm64": "0.5.17", "@opentui/core-win32-x64": "0.5.17" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-75vMaKPKc6tnnUWEultPxiiSU0kqNSdAjbcsFGQecjCF4Z1Jrr4NZBiRo3SoMciai/nq24JCiYcVqPjaKtl/6Q=="],
"@opentui/core-darwin-arm64": ["@opentui/[email protected]4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wWmw41wRMBoI0lN9mgyzGRwYujwwfNkBP6jYM99k4Bx2XkeSZ3sCeu5bwX9vEJPYqjiBShbDhc0NnVSP7NwzVg=="],
"@opentui/core-darwin-arm64": ["@opentui/[email protected]7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ptobzW+1zM29nHvadwy+8uW9jvV8NJr63vcQXWe2rQNtpwN/6k7lEXWiS5LndeBs9AMV5tTsXMEMhttgts4BDQ=="],
"@opentui/core-darwin-x64": ["@opentui/[email protected]4", "", { "os": "darwin", "cpu": "x64" }, "sha512-7smHKDH8IhUaBsgYuAClMl2mHWu+yjpMrtdw3wEvBVtCzKMrHi/TJ5PtydO+IfUzR3BXpVubdbR1irD8BTcR/w=="],
"@opentui/core-darwin-x64": ["@opentui/[email protected]7", "", { "os": "darwin", "cpu": "x64" }, "sha512-6yLbnElIZPxhZSrZSyS4mB5+k0YFlpRO0Q8uX4sLFTmvOy8tIeDPFcTGRQqZQ1Rk938tiAhf7iyAX3P1wL4Liw=="],
"@opentui/core-linux-arm64": ["@opentui/[email protected]4", "", { "os": "linux", "cpu": "arm64" }, "sha512-xH1hP+NaLySEJeZkl21NlkZBMddMfQ1jU8NeX1AEBc2GNBOvDXU4Ud/xw87SrAvU1xG9K7/9C4oy4AmIMEpVGg=="],
"@opentui/core-linux-arm64": ["@opentui/[email protected]7", "", { "os": "linux", "cpu": "arm64" }, "sha512-HO3dYgzQBGJhe58uJauVClIJg3vToYl/LZa7ZzqxQ+3vZ//cxaJ7tgXhHWR2uJE02550yI69e5nqBpSOTT9E8Q=="],
"@opentui/core-linux-arm64-musl": ["@opentui/[email protected]4", "", { "os": "linux", "cpu": "arm64" }, "sha512-mnBBAuTb92NiRLAjOD755tS8/tNQemDztbg9tMvoCT90G52FtVrRb31Ge6OrYqfm0c9DkZGhEBOhunsId/4zSA=="],
"@opentui/core-linux-arm64-musl": ["@opentui/[email protected]7", "", { "os": "linux", "cpu": "arm64" }, "sha512-QMRdDbmR0LwpmX1HXVbN+V3A44/QsbWzMsF3V7cEz7MaDq6RNBs3CUFqfkPorKgPtI75T0AxM7FdJnjt9C4d0Q=="],
"@opentui/core-linux-x64": ["@opentui/[email protected]4", "", { "os": "linux", "cpu": "x64" }, "sha512-Hkk4kaDGMcn9bmJFJPW3/QOGGbPuWe3sCFV/CkiVb4+bCvcTm7EXQr4QTAA63BYy2dKE5bUFUi1zZlyeMkWpnA=="],
"@opentui/core-linux-x64": ["@opentui/[email protected]7", "", { "os": "linux", "cpu": "x64" }, "sha512-lNmtQlcwsio4IVo2trc0QoMyun31UsN0mzmSc2BtYMOeOC5e0PyWaEAZWJb/g6AinOs9xwJGL02JAN5J3QPWsw=="],
"@opentui/core-linux-x64-musl": ["@opentui/[email protected]4", "", { "os": "linux", "cpu": "x64" }, "sha512-ngJ+U2grOGEteeQvZdAJNqn09M+At5mfWInauK5aS427bea1yLo+e6hor/CRmbn9SxEEF+SwoyekaOrLPWyU7w=="],
"@opentui/core-linux-x64-musl": ["@opentui/[email protected]7", "", { "os": "linux", "cpu": "x64" }, "sha512-M2RatQaeET/werHFSH+cg7OkQk/Wn0VoVXZLLEL7DiCDTuhZyMfi3uFDz0+R1IGiiUITqHWpZdZ1LdBY8NCjwQ=="],
"@opentui/core-win32-arm64": ["@opentui/[email protected]4", "", { "os": "win32", "cpu": "arm64" }, "sha512-T9kNqKXg2jysmTsyyZ1A8LBQotFBM+iPjzyRslxyexqrs8a1UcmxbApEo+COtxQuqukMTbvwyqEdAT8vcmxEkQ=="],
"@opentui/core-win32-arm64": ["@opentui/[email protected]7", "", { "os": "win32", "cpu": "arm64" }, "sha512-MyVVpZ0CyGDQ+g4eM0zURqEd/9jDrBIPcZdLyZOmWbdoVu36PLM9KAkqwl/n5ASn/rOrQd+d14jUAsZJvFTqzw=="],
"@opentui/core-win32-x64": ["@opentui/[email protected]4", "", { "os": "win32", "cpu": "x64" }, "sha512-mqKSkab8VdMLSmMdocna7+BTyMoIutkVXOV9lfmPrBO2g7Np5c6c4SJ4QIVZqPvast11XyT0/7FfXOYLKAS72w=="],
"@opentui/core-win32-x64": ["@opentui/[email protected]7", "", { "os": "win32", "cpu": "x64" }, "sha512-etkuzyfclRX9wel4ivCOk90dUdZNtjK7U6vr7ZHmwkZyOik34ec3Gse0B/jyk192Xdx0l50Tl2xgC1h68XRiEQ=="],
"@opentui/solid": ["@opentui/[email protected]4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.14", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-bBRl34mZ0wFGhjHX6y1VNiZmJ3DSm2DVPm0PNSVDpvq9qxRHuywEPTCn/Lgte0C9450tgf11cDqcE3pR9cZwVA=="],
"@opentui/solid": ["@opentui/[email protected]7", "", { "dependencies": { "@babel/core": "7.29.7", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.17", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-YShxRqJYyg3ysh4qR2NpCDhr+I1OpIfmcn8nQch7EsBpmUOZh8INqThQI722tXBULOAYhOQWqzr5APjJxYDFbQ=="],
"@parcel/watcher": ["@parcel/[email protected]", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
@@ -452,6 +536,56 @@
"@redis/time-series": ["@redis/[email protected]", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-+fJOB8mN1z5WRakCJNh5lVdz1KqvHGSOistsKeitD24QsCtPwyRQXWC0iq6T/i/ubLE/YcguZqPm8F598MKk4A=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-a31GFbL70GlGUGhn8goVjRLtdDG1urLCJFWLZ7JcxdHK9pPvCohLFtYLgJDdNbtrNZSrJl1u9bK05ISsSC2EpA=="],
"@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-IRv757RLiPGlNxAX5JXU0eiUlUPvKiwFRbkqLd+gywptLK4uSEg1qkl6en6r0cXMazSnWUWOBABv+QfuLV3n5Q=="],
"@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UkzI35Fnz6+MJNATdnjnZ3TpdG/rXab8h7oDOD9R0zDVIFmuuYrvgUj8V8mh+A//+LR4TlEQo4ZW4RhyRtWv3A=="],
"@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-C3AyMtAnKQ/jSkZ0chIoWbPXRbFz8JnPLhl1ULecEhcaOGkT19xt7KmO8BulQxt9S/XideHRXvsVQUbGj4JCOg=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-M8yAdBMyHh14JLeXDNGvEkljzuHdcoLBQ399O4Fp4nvX51lueuEY/5U2G9Ci0D6/bQlBp/3JLiBQH55STwwbwg=="],
"@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-WQjuOKGh2FN98ZK6i0uM6M6YqnPEds85p3oRkrxiEeTQo/p7RpuU8c9MwQ88LfgwVAzohumlzbQQSikz4Zw9rw=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-5tDLZG5s9WyVKoEHgwos6FpKrgpotl61EYeHSLSVkQzMzTsaClwEr8mok9rm3y734zjiGInG4dfw/56ebWVvEQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-Mh5KbLeplM2HX6lQI18jsxSsqvPW8d+Hyk8HtY6Zl93woMitRSCZ92rBEzq6v8bfZGlpyMgj4P/sKW4KCjhH3A=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-QZrrwHnznTv6y4iiHtCqDN+ZsF4qNFQObMOLgPrbxhh9O5o4rgoUe5OnkqG/ceCObMrAjhQt+eQ6HSDzBFgVMw=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-4vLehpXSwlkltnvk5ETB+Hz+d3xayZS3NamZ5Vq4bSo+IUoIALovEJF1YgWPMjHwN7R5o/R4BpP9h3q8EK1xAA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-SEN7TTTOdXJKZmsWKOnQeVfgy3Dje/08ISXWwFhTggQ3SxjuIaChvmOB5ZDZqJNlBjaXrV6nAHraqEAvdLGqcw=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-1LDsQMRMCEdGF9Dih5qyh6fF1LK3NnA+pAtE2+9AgZwUdXyd19TOBVC9crSB63iZBHR4vOG7h8gN/LcUH3J7xw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-v3D2tAfRriqGOi+5Ac5CiYbBo2NXtLVxJlludTvUZRj502lVMFeVIuvvWbz4qCrotn3e3Uvpvh7FcMpG2QxxXQ=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-db5q6dP189V2+JkMpsXBl8ym6bC0x8ydZ47fUJZ9YdM6T2OF5uqNzX9HaAZHRP/lqjA9bJeNwi0qw1/2/37dUA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-nArAVDmrL5ix9UoipcKEsYJw4kMUQCIPYRWuYF2POOC7jd9rmN95d/48ewsYFC/OdtwEx6VYYWYxrp8FbMOU2Q=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-2dc7LElu7EVSvpQT8epNnBLa02wlqilNYqHN0FA/RaKqCr9U9ZFYQ0Eh4sfgSzLneLDuJsJRjcezR/l2UyYGXA=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-9PYq2IEzSwXcvQLERcT9LKXRzw8J05GFTbJHA2anJR2WmlRjA1esgXVYhanp+bmjKsDVoZeU2L7Z2pRClLcZTw=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-vMh+mt0cw1BHldd8cqaU6A5s1W4EUKl6+8MRsv86mj954L4xyruKD7qPXMUI2icImadAZARK10h0onaGTLmCVg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-PpiKU+sWbqDGc0U0su8oXiyeyqm0UQjmaaPKHktX+DaLe6YKJwW2YVD0f2AXHdVvblZbRqpwRVClWP2F5HCqrQ=="],
"@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/3enY/AmaIt8IS0fWjX4ztYVPBqkj49cQTy/hCryhBFaEzDfJwwEolq9sxvyWAM2qmsmVuPdeRg5JXSrmeqvJg=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-zsEP/QDuet52hhwvi5T1VPg0/Tkms0m3zkVv7CPsznvhdO4pL4oJTMz26EJsiCe18OeLKPgpTu3XVO3C1eq3ew=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-4ESknW4Db+kqvazVOfsReYJaHxcIsOqcgwjKV2J1tG5Bn4RVf5ocTQKSGuq2HWBJcOLuyim8IWX8DuH6nKmELA=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-3B8VCQyIyv7FQxYYhb3HpR3bIpronR20txjMSffSoz/sm/DDs3/49CFpEueOsvq9qW9sWoI/6pi6mtciD9NXFg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-YOoDkKh2oQxOxRB5D9AR8sj3qpMShpmyTkjojiBjMGigz3caV7UvCnXWWUQC0TBmrZ5zuHe2pWl8S+cMfvXzRg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-qTv4bG+eKJKifzxNJevMUh8L6CjJrz9/Kn1IbP2F5bo0tBzNIR7HMg4vni6XmDzcKUl00lHHY+J5ZgjbGiujOQ=="],
"@sigstore/bundle": ["@sigstore/[email protected]", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A=="],
"@sigstore/core": ["@sigstore/[email protected]", "", {}, "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g=="],
@@ -496,10 +630,22 @@
"@tufjs/models": ["@tufjs/[email protected]", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="],
"@types/babel__core": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
"@types/estree": ["@types/[email protected]", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="],
"@types/whatwg-mimetype": ["@types/[email protected]", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@types/ws": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="],
"@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
@@ -604,6 +750,8 @@
"buffer-equal-constant-time": ["[email protected]", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"buffer-image-size": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
"bun-ffi-structs": ["[email protected]", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
"bun-pty": ["[email protected]", "", {}, "sha512-IUF/B3FANo8vIQ775Zt7Er7lphMpYMhLkes45am2WE8FaVI7KRYtj1rQwliZ18b6GpNzBNWcl7sz9QT5wDFBeQ=="],
@@ -684,6 +832,8 @@
"es-errors": ["[email protected]", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"esprima": ["[email protected]", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
@@ -720,6 +870,8 @@
"fs.realpath": ["[email protected]", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"fuzzysort": ["[email protected]", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="],
@@ -750,6 +902,8 @@
"gtoken": ["[email protected]", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="],
"happy-dom": ["[email protected]", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-5DDsf4QmEIlRL6NkCv/fxSjYHHkZceL/IaYQdJc2h3G7DRTEai++VTjbfL0LB9geW2VrA3+vrjF3Efx+OTtX0Q=="],
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hosted-git-info": ["[email protected]", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
@@ -794,6 +948,8 @@
"is-number": ["[email protected]", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-what": ["[email protected]", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
"is-wsl": ["[email protected]", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"isexe": ["[email protected]", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
@@ -844,6 +1000,8 @@
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"merge-anything": ["[email protected]", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
"micromatch": ["[email protected]", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime": ["[email protected]", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
@@ -876,6 +1034,8 @@
"nanoevents": ["[email protected]", "", {}, "sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q=="],
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"negotiator": ["[email protected]", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="],
"node-addon-api": ["[email protected]", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
@@ -938,12 +1098,14 @@
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"pkce-challenge": ["[email protected]", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"pkg-up": ["[email protected]", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="],
"postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="],
"powershell-utils": ["[email protected]", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="],
@@ -968,6 +1130,8 @@
"rimraf": ["[email protected]", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.64.3", "@rollup/rollup-android-arm64": "4.64.3", "@rollup/rollup-darwin-arm64": "4.64.3", "@rollup/rollup-darwin-x64": "4.64.3", "@rollup/rollup-freebsd-arm64": "4.64.3", "@rollup/rollup-freebsd-x64": "4.64.3", "@rollup/rollup-linux-arm-gnueabihf": "4.64.3", "@rollup/rollup-linux-arm-musleabihf": "4.64.3", "@rollup/rollup-linux-arm64-gnu": "4.64.3", "@rollup/rollup-linux-arm64-musl": "4.64.3", "@rollup/rollup-linux-loong64-gnu": "4.64.3", "@rollup/rollup-linux-loong64-musl": "4.64.3", "@rollup/rollup-linux-ppc64-gnu": "4.64.3", "@rollup/rollup-linux-ppc64-musl": "4.64.3", "@rollup/rollup-linux-riscv64-gnu": "4.64.3", "@rollup/rollup-linux-riscv64-musl": "4.64.3", "@rollup/rollup-linux-s390x-gnu": "4.64.3", "@rollup/rollup-linux-x64-gnu": "4.64.3", "@rollup/rollup-linux-x64-musl": "4.64.3", "@rollup/rollup-openbsd-x64": "4.64.3", "@rollup/rollup-openharmony-arm64": "4.64.3", "@rollup/rollup-win32-arm64-msvc": "4.64.3", "@rollup/rollup-win32-ia32-msvc": "4.64.3", "@rollup/rollup-win32-x64-gnu": "4.64.3", "@rollup/rollup-win32-x64-msvc": "4.64.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-iIMWbptLYsZdGTJMH8ngApXdsCatE6bCMv2eqGIfGE1++kwh7TCWBdWKK0FKC0yx++196vGK5VdagzRXSLMa8A=="],
"run-applescript": ["[email protected]", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
"s-js": ["[email protected]", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="],
@@ -1004,6 +1168,10 @@
"solid-js": ["[email protected]", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg=="],
"solid-refresh": ["[email protected]", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
"source-map-js": ["[email protected]", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
"spdx-exceptions": ["[email protected]", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="],
"spdx-expression-parse": ["[email protected]", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ=="],
@@ -1058,6 +1226,12 @@
"validate-npm-package-name": ["[email protected]", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
"vite-plugin-solid": ["[email protected]", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.0.0 || ^7.0.0", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-7ZVBt8rpoyqmlwin2kRIUveaHoF6/kulY7gsnD+qFh4nS29V4OPAnw+ojoAspXIjObiL9o1xh9a/nTuYHm02Rw=="],
"vitefu": ["[email protected]", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"vscode-jsonrpc": ["[email protected]", "", {}, "sha512-qETYf9PCl1lVfwR+oBmMfc4vP2nqLNA35ABxorw3j06+xlKRgDVxbLgKm4Y/xPL0YWdeDjLoI4ImICIT3nB0zQ=="],
"vscode-languageserver-protocol": ["[email protected]", "", { "dependencies": { "vscode-jsonrpc": "9.0.3", "vscode-languageserver-types": "3.18.4" } }, "sha512-CCXbyr99dafRJzJH/0Ryk7ly5T8XPmSR0//OuF2w4m+9JiOin7zcAT5iET8GRE7Z9taFBejb3GbM0a0mbjdbrg=="],
@@ -1072,6 +1246,8 @@
"webidl-conversions": ["[email protected]", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-mimetype": ["[email protected]", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
"whatwg-url": ["[email protected]", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"which": ["[email protected]", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
@@ -1134,11 +1310,13 @@
"@opencode/plugin/zod": ["[email protected]", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
"@opencode/simulation/@opentui/core": ["@opentui/[email protected]", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.14", "@opentui/core-darwin-x64": "0.5.14", "@opentui/core-linux-arm64": "0.5.14", "@opentui/core-linux-arm64-musl": "0.5.14", "@opentui/core-linux-x64": "0.5.14", "@opentui/core-linux-x64-musl": "0.5.14", "@opentui/core-win32-arm64": "0.5.14", "@opentui/core-win32-x64": "0.5.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-tfQ+PWQyeBnYloB3diEcPqbILv14xemH5jjAEICfPuyNDtGBqrjhUtThrhbvFRuPMcj6IEeXrAk6VE8e91h0kg=="],
"@opencode/simulation/ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"@opentui/core/web-tree-sitter": ["[email protected]", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
"@opentui/solid/@babel/core": ["@babel/[email protected]8.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@opentui/solid/@babel/core": ["@babel/[email protected]9.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
@@ -1166,6 +1344,8 @@
"htmlparser2/entities": ["[email protected]", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"micromatch/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"minipass-flush/minipass": ["[email protected]", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"minipass-pipeline/minipass": ["[email protected]", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -1196,10 +1376,10 @@
"tar/yallist": ["[email protected]", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"tinyglobby/picomatch": ["[email protected]", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"tree-sitter-bash/node-addon-api": ["[email protected]", "", {}, "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg=="],
"vite-plugin-solid/@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"wrap-ansi/string-width": ["[email protected]", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"wrap-ansi-cjs/ansi-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
@@ -1214,6 +1394,24 @@
"@isaacs/cliui/string-width/emoji-regex": ["[email protected]", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@opencode/simulation/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wWmw41wRMBoI0lN9mgyzGRwYujwwfNkBP6jYM99k4Bx2XkeSZ3sCeu5bwX9vEJPYqjiBShbDhc0NnVSP7NwzVg=="],
"@opencode/simulation/@opentui/core/@opentui/core-darwin-x64": ["@opentui/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-7smHKDH8IhUaBsgYuAClMl2mHWu+yjpMrtdw3wEvBVtCzKMrHi/TJ5PtydO+IfUzR3BXpVubdbR1irD8BTcR/w=="],
"@opencode/simulation/@opentui/core/@opentui/core-linux-arm64": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-xH1hP+NaLySEJeZkl21NlkZBMddMfQ1jU8NeX1AEBc2GNBOvDXU4Ud/xw87SrAvU1xG9K7/9C4oy4AmIMEpVGg=="],
"@opencode/simulation/@opentui/core/@opentui/core-linux-arm64-musl": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-mnBBAuTb92NiRLAjOD755tS8/tNQemDztbg9tMvoCT90G52FtVrRb31Ge6OrYqfm0c9DkZGhEBOhunsId/4zSA=="],
"@opencode/simulation/@opentui/core/@opentui/core-linux-x64": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-Hkk4kaDGMcn9bmJFJPW3/QOGGbPuWe3sCFV/CkiVb4+bCvcTm7EXQr4QTAA63BYy2dKE5bUFUi1zZlyeMkWpnA=="],
"@opencode/simulation/@opentui/core/@opentui/core-linux-x64-musl": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-ngJ+U2grOGEteeQvZdAJNqn09M+At5mfWInauK5aS427bea1yLo+e6hor/CRmbn9SxEEF+SwoyekaOrLPWyU7w=="],
"@opencode/simulation/@opentui/core/@opentui/core-win32-arm64": ["@opentui/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-T9kNqKXg2jysmTsyyZ1A8LBQotFBM+iPjzyRslxyexqrs8a1UcmxbApEo+COtxQuqukMTbvwyqEdAT8vcmxEkQ=="],
"@opencode/simulation/@opentui/core/@opentui/core-win32-x64": ["@opentui/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-mqKSkab8VdMLSmMdocna7+BTyMoIutkVXOV9lfmPrBO2g7Np5c6c4SJ4QIVZqPvast11XyT0/7FfXOYLKAS72w=="],
"@opencode/simulation/@opentui/core/web-tree-sitter": ["[email protected]", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
"babel-plugin-module-resolver/glob/minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="],
"babel-plugin-module-resolver/glob/minipass": ["[email protected]", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
+10 -6
View File
@@ -12,21 +12,22 @@ and Antigravity adapters ship in 1.1–1.3). Generated: do not edit by hand.
| agents | ENFORCED | `packages/opencode/test/plugin.test.ts` | Registered by agent.transform with wildcard-deny scoping; never touches built-ins or defaults. |
| skills | ENFORCED | `packages/opencode/test/plugin.test.ts` | One shared SKILL.md tree registered at runtime; per-agent visibility via skill deny rules. |
| tools | ENFORCED | `packages/opencode/test/plugin.test.ts` | es_* tools registered direct (codemode:false) with a permission action per tool; seat-scoped. |
| permissions | ENFORCED | `packages/core/test/policy.test.ts` | permission.evaluate + tool.execute.before enforce the write/read/shell policy; control files (incl. the engine-sealed evidence cache) denied; research seats get no host websearch/webfetch. Every agent shell runs under bubblewrap by seat kind (cached-web seats offline); seats are refused without it and the user's agents fall back to the weaker argv-aware text policy. |
| permissions | ENFORCED | `packages/opencode/test/scoping.test.ts` | permission.evaluate + tool.execute.before enforce the write/read/shell policy; control files (incl. the engine-sealed evidence cache) denied; research seats get no host websearch/webfetch. Every agent shell runs under bubblewrap by seat kind (cached-web seats offline); seats are refused without it and the user's agents fall back to the weaker argv-aware text policy. |
| hooks | ENFORCED | `packages/opencode/test/hooks.test.ts` | Claude hook schema bridged in-process; Stop is emulated after the turn (advisory for plain chat). |
| question | ENFORCED | `packages/opencode/test/plugin.test.ts` | Session forms only humans can answer; primaries may ask, autonomous subagent seats may not. |
| subagents | ENFORCED | `packages/opencode/test/plugin.test.ts` | Depth 1; hidden seats are unlisted but invocable by ID, and only from the seats allowed to spawn them. Factory seats are not offered Code Mode execute. |
| question | ENFORCED | `packages/opencode/test/scoping.test.ts` | Session forms only humans can answer; primaries may ask, autonomous subagent seats may not. |
| subagents | ENFORCED | `packages/opencode/test/scoping.test.ts` | Depth 1; hidden seats are unlisted but invocable by ID, and only from the seats allowed to spawn them. Factory seats are not offered Code Mode execute. |
| compaction | ENFORCED | `packages/opencode/test/plugin.test.ts` | Session compaction injects the compact factory-state block for factory seats. |
| websearch | ENFORCED | `packages/opencode/test/research.test.ts` | The configured provider registers as the host websearch; results are cached and citable. Research, scout and auditor seats are cached-only (es_research_search/fetch) under an offline (--unshare-net) sandbox; host web caching applies only in a project that already has .factory/, and the cache is engine-sealed (planted entries refused). |
| lsp | ENFORCED | `packages/opencode/test/lsp.test.ts` | The lsp config key is read and served by our own manager (v2 accepts but does not run it). |
| panels | ENFORCED | `packages/opencode/test/panels.test.ts` | Four session.panel dashboards (factory, LSP, hooks, brainstorm), rendered by OpenTUI and refreshed on server changes; Esc/q closes a panel, f toggles fullscreen. The factory dashboard leads with the run headline, seats and research progress; a prompt-footer indicator shows the stage and the running seat. |
| research | ENFORCED | `packages/core/test/research.test.ts` | Engine-sealed cache (#52), fragment-level quote verifier (every ellipsis fragment >= 12 chars; code excerpts one contiguous span of at most 60 lines), auditor, providers and the websearch bridge. |
| brainstorm | ENFORCED | `packages/opencode/test/brainstorm.test.ts` | Lens fan-out, record/score/complete tools, dedupe, rubric, shortlist with a forced outlier. |
| harvest | ENFORCED | `packages/opencode/test/harvest.test.ts` | Fail-closed SPDX detection, provenance profiles, policy-enforced matrix, clean-room specs. |
| research | ENFORCED | `packages/opencode/test/research.test.ts` | Engine-sealed cache (#52), fragment-level quote verifier (every ellipsis fragment >= 12 chars; code excerpts one contiguous span of at most 60 lines), auditor, providers and the websearch bridge. Research-only runs complete through the deep-researcher (thesis/antithesis/synthesis at depth 1) at DONE. |
| brainstorm | ENFORCED | `packages/opencode/test/brainstorm.test.ts` | Lens fan-out, record/score/complete tools, dedupe, rubric, shortlist with a forced outlier. Runs are id-scoped (the human's /brainstorm is the default run); the grill and the factory run callable brainstorms at depth 1 and get the shortlist as JSON. |
| harvest | ENFORCED | `packages/opencode/test/harvest.test.ts` | Fail-closed SPDX detection, provenance profiles, policy-enforced matrix, clean-room specs. Runs are id-scoped (the human's /harvest is the default run); es_harvest_target gives the grill, factory, scout and harvester deterministic verdicts, and prior-art search is shared with brainstorm callers. |
| design | ENFORCED | `packages/opencode/test/design.test.ts` | Queereye interview, DTCG tokens, contrast gate, generated guide and drift check; phase-02 specs/webref/csf/tui renders and the phase-03 ledger bundle with its cite-gate receipt. |
| claims | ENFORCED | `packages/core/test/research.test.ts` | Witnessed claims and dossiers: the evidence cache, dossiers and the claim ledger are tool-only; planted cache entries are refused (engine seal) and every VERIFIED quote and code span is re-checked against the bytes on disk. |
| audit | ENFORCED | `packages/opencode/test/fires.test.ts` | Code-audit pair: only auditor seats record verdicts; every finding is witnessed on disk (a hallucinated line is refused); quote fragments carry >= 12 chars each and a thesis pass needs a witnessed invariant; verdicts block (an opened phase audit holds the merge; audit.phase required also holds a missing one until it passes or a human dismisses it). |
| scout | ENFORCED | `packages/opencode/test/fires.test.ts` | OSS scout: cached-only web under an offline (--unshare-net) sandbox, fail-closed license verdicts computed by core (adopt only for verified permissive licenses), OSV advisories cited. |
| research-fires | ENFORCED | `packages/opencode/test/fires-research.test.ts` | Research fires: the whole research loop on the real host with loopback-only servers — grounded quotes survive the audit, failover records provenance, blocked never fails over, planted sources are refused, retractions degrade to STALE, renders snapshot. |
## claude
@@ -50,6 +51,7 @@ and Antigravity adapters ship in 1.1–1.3). Generated: do not edit by hand.
| claims | UNSUPPORTED | — | The native Claude Code adapter (marketplace plugin, subagent frontmatter, hooks.json) ships in 1.1. |
| audit | UNSUPPORTED | — | The native Claude Code adapter (marketplace plugin, subagent frontmatter, hooks.json) ships in 1.1. |
| scout | UNSUPPORTED | — | The native Claude Code adapter (marketplace plugin, subagent frontmatter, hooks.json) ships in 1.1. |
| research-fires | UNSUPPORTED | — | The native Claude Code adapter (marketplace plugin, subagent frontmatter, hooks.json) ships in 1.1. |
## pi
@@ -73,6 +75,7 @@ and Antigravity adapters ship in 1.1–1.3). Generated: do not edit by hand.
| claims | UNSUPPORTED | — | The Pi extension (in-process tool_call blocking, sequential pi -p roles) ships in 1.2. |
| audit | UNSUPPORTED | — | The Pi extension (in-process tool_call blocking, sequential pi -p roles) ships in 1.2. |
| scout | UNSUPPORTED | — | The Pi extension (in-process tool_call blocking, sequential pi -p roles) ships in 1.2. |
| research-fires | UNSUPPORTED | — | The Pi extension (in-process tool_call blocking, sequential pi -p roles) ships in 1.2. |
## antigravity
@@ -96,6 +99,7 @@ and Antigravity adapters ship in 1.1–1.3). Generated: do not edit by hand.
| claims | UNSUPPORTED | — | The Antigravity adapter is rebuilt to the documented hook format and validated in CI in 1.3. |
| audit | UNSUPPORTED | — | The Antigravity adapter is rebuilt to the documented hook format and validated in CI in 1.3. |
| scout | UNSUPPORTED | — | The Antigravity adapter is rebuilt to the documented hook format and validated in CI in 1.3. |
| research-fires | UNSUPPORTED | — | The Antigravity adapter is rebuilt to the documented hook format and validated in CI in 1.3. |
## Hook bridge detail
+10
View File
@@ -19,4 +19,14 @@ plugin option is ignored with a warning (TUI toast and `/config`). Generated: do
| `licenseWhitelist` | array | `["MIT","Apache-2.0","BSD-2-Clause","BSD-3-Clause","ISC","0BSD","Unlicense","CC0-1.0"]` | SPDX ids darkharvest may depend on or vendor (a harvest plan may only narrow it); everything else is clean-room only. |
| `audit` | object | `{"phase":"optional"}` | Code-audit gating tunables (set with `es config set audit.phase required`). |
Nested `models.*` keys (fail-closed: malformed refs and unknown agent ids are rejected at load; a
configured model missing on the host refuses that seat at launch):
| field | description |
| --- | --- |
| `models.fast` | Model for fast-tier seats (lenses), "provider/model". |
| `models.balanced` | Model for balanced-tier seats (programmer, QA, research). |
| `models.deep` | Model for deep-tier seats (factory, grill, managers, critics). |
| `models.agents` | Per-agent model overrides by agent id. |
`es config show` and `/config` print the effective config and which files contributed.
+30 -3
View File
@@ -56,7 +56,10 @@ Answer the interview (one question at a time). Set the **spend ceiling**
explicitly — the run cannot start without it. When the design tree is settled,
approve it:
- TUI: `/es-approve` previews what you are approving, then points at the terminal; or
- TUI: `/es-approve` previews what you are approving, asks for your passphrase (masked), and records it; or
- browser: `es-fleet web` prints a one-time URL (from a checkout:
`bun packages/fleet/bin/es-fleet.js web` — `es-fleet` is private, never
published to npm); the approvals page previews the subject and hashes, then asks for your passphrase (masked) and records with channel `web`; or
- terminal: `es approve frontier` (asks for your passphrase)
## 3. Research and spec
@@ -71,8 +74,9 @@ unattended run. Either way the factory drives RESEARCH and then SPEC:
Seats run in the foreground, so a research turn can take minutes. That is
work, not a stall: see "Watching a run" below. When the spec is ready,
approve it the same way (`/es-approve` previews it; `es approve spec` records
it).
approve it the same way (`/es-approve` records it in the TUI, the approvals
page records it in the browser, `es approve spec` records
it in a terminal).
## 4. Autonomous build
@@ -104,6 +108,10 @@ long ago it acted.
waits on you.
- A toast announces stage changes, and also a paused factory after three
turns without progress (run `/factory` to continue).
- **Web** (`es-fleet web` prints a one-time loopback URL — from a
checkout: `bun packages/fleet/bin/es-fleet.js web`): the fleet
overview and per-task pages across the whole fleet — DAG, spend against
the ceiling, pending approvals, live updates with a stale banner (#130).
- **Headless**: `es factory run --headless` prints JSON lines.
- It emits a `progress` event every 30 s while a turn runs.
- `stalled` carries its evidence (the headline, last activity, running
@@ -112,6 +120,11 @@ long ago it acted.
- `quiet`: lifecycle and progress only;
- `info`: one line per tool call;
- `debug`: raw harness events with long strings clipped.
- `--events jsonl` replaces the human lines with a versioned envelope per
line (`turn-metrics` after every turn: spend delta + tool activity);
`--events-file <path>` writes the envelopes to a file instead.
SIGINT/SIGTERM cancel the run (exit 130, resumable);
`--turn-timeout S` aborts a hung turn. See `docs/HEADLESS.md`.
`es status` from an agent's shell cannot verify the run (the private state
dir is masked there); agents call the `es_status` tool, which shows the same
@@ -129,10 +142,24 @@ the run exposed (those are M6 bugs to fix, not waivers). The run's artifacts
live under `.factory/` (specs and roadmap tracked; runtime, runs and worktrees
ignored).
## 7. Deep research (no build)
For a question with no code to build, skip the factory: `es research deep
"<question>" --output <dir> --max-usd N` (or `/research deep <question>
--max-usd N` in the TUI) starts a research-only run and drives the
deep-researcher headlessly. Watch it with `es status` in the output dir:
thesis (`alpha.md`), antithesis (`beta.md`), then the synthesizer's
`REPORT.md`, completing at DONE. Same ceiling/STOP/halt discipline, same
sealed evidence.
## Notes
- Provider keys come from the environment or the harness store; they are never
written into `.factory/`.
- Upgrading from 0.7: the plugin options `search_engine`, `max_iterations`
and `mode` were removed in 1.0 — a config that still carries them warns
once (`Ignored unknown plugin options: …`) and otherwise loads. Delete
those keys; only `models` per tier is needed (see §1).
- Budgets use host-reported usage where exposed; estimates are labelled
"estimated".
- A failed run is evidence: fix the harness, re-grill if the design changed,
+263
View File
@@ -0,0 +1,263 @@
# Fleet (Phase 3)
The fleet (`packages/fleet`, `es-fleet`) runs task DAGs concurrently on one
machine: isolated git worktrees per task (#124), one headless run per task
(#125), and a read-only localhost telemetry bus (#126). Starting the fleet
spends money, so `es-fleet` is human-only except `status` (and `--help`).
Fleet state lives in `<stateDir>/fleet/` (masked inside agent sandboxes):
`state.json` (daemon status), `tasks.json` (the DAG), `worktrees.json`
(the registry, #124), `daemon.pid`, `fleet.sock`, `token`.
## Task model (#123)
```text
{ id, title, kind: "factory-run" | "research-run",
deps, trigger: all_success (default) | one_success,
input: { objective, tier? }, ceilingUSD (mandatory, positive),
policy: {strategy: "fail"} (default) | {strategy: "retry", maxAttempts}
| {strategy: "replan"},
status: pending | ready | running | waiting-human | done | failed
| cancelled,
attempts, reason?, result?: { spendUsd, summary? } }
```
## Scheduler (#123)
`schedule(dag, { concurrency, fleetCeilingUsd })` is pure: same DAG and caps
give the same `{ unblock, start, cancel }`. No clocks inside; `applyDecision`
and `reportTask` take `now` for stamps.
- A task is **ready** when all deps are `done` (`all_success`), or when any
dep is `done` (`one_success`). Tasks without deps are immediately runnable.
- A task that can never run is **cancelled**: a failed/cancelled dep
(`all_success`), or all deps terminal with none done (`one_success`).
- **Starts** are id-sorted and bounded by `concurrency` and the summed task
ceilings against the fleet ceiling. `waiting-human` tasks free both.
- **Failure policy** (per task, on its own failure): `fail` goes terminal and
the next `schedule` cancels its dependents; `retry(n)` re-queues while
`attempts <= maxAttempts`; `replan` parks the task as `waiting-human`.
- Worker reports for terminal tasks are ignored (at-least-once delivery).
CLI: `es-fleet task add --file <task.json>` (one object or an array;
human-only), `task list [--json]`, `task cancel <id>` (human-only).
## Worktree coordinator (#124)
- One worktree per task at `<repo>/.fleet/worktrees/<taskId>/` (gitignored)
on branch `fleet/<taskId>`, from a recorded base commit. Absolute symlinks
pointing into the repo are rebased to the worktree on allocation.
- The registry (`<stateDir>/fleet/worktrees.json`) maps taskId → dir,
branch, base, createdAt, status
(`allocating`/`allocated`/`merged`/`abandoned`), under
the shared fleet lock. Allocation pre-registers `allocating` before the
git work and flips to `allocated` after (never holding both locks); `gc`
holds the fleet lock for its whole read → decide → persist inside the
repo lock (one lock order, repo → fleet), treats `allocating` as live,
and drops only stale `allocating` records whose dir and branch are gone.
A double allocation is refused.
- Release removes the checkout and marks the record. Abandoned branches are
anchored at `refs/fleet-salvage/<taskId>` before deletion; merged branches
are kept. `es-fleet gc` (human-only) salvages and removes orphaned
worktrees and drops stale records.
- Landing (`landTask`) is transactional: gates run in the task worktree
first — a gate failure marks the task `failed` with the gate reason and
merges nothing — then a `--no-ff` merge onto
`fleet/integration/<dagId>` inside a throwaway worktree with the pre-merge
tip captured. A merge failure aborts only with `MERGE_HEAD` present and
reports `conflict: true` only with unmerged paths; any other failure
returns its original reason with `conflict: false`. A conflict aborts,
verifies the rollback, and reports `conflict: true` so the caller marks
the task `waiting-human`.
- The base branch is never checked out, reset, or merged: the suite asserts
its ref is stable across every operation.
- Policy: `.fleet/` is deny-write for every agent outside its own task
worktree, and seats may not name other tasks' `.fleet/` paths in the shell.
## Workers (#125)
- One headless run per task: `es factory run --headless --events jsonl
--cwd <worktree>`, spawned with argv only (never a shell). There is
deliberately no `--max-usd` on that argv — `factory run` has no such flag —
so the daemon provisions each task's ceiling into its run state beforehand
(`prepareTaskRun`, via core's `Factory.provisionRun` /
`beginResearchRun`), where the factory guard enforces it. `research-run`
tasks are refused until a headless research driver exists.
- Headless events fold into task reports (`foldWorkerEvent`, pure):
`waiting` → `waiting-human`, `done` → `done`, `halted`/`stalled`/
`turn-cap`/`error` → `failed`, `cancelled` → `cancelled`; `turn-metrics`
accumulate spend and tokens without a report. A run that exits without a
final event reports `cancelled` on exit 130, else `failed`.
- `WorkerSupervisor` never double-starts a tracked task; a daemon restart
reattaches to live pids (persisted in `workers.json`) and reports the dead
ones as failed for the failure policy. `es-fleet stop` SIGTERMs every
worker and records resumable cancellations.
- The StillDown circuit breaker refuses restarts for tasks that keep
crashing, and pauses all launches on mass worker death; both recover after
the cooldown.
- `FleetDaemon.tick` (idempotent) wires it together: schedule → allocate →
prepare → launch → fold reports → land done tasks → release; `stop` is the
kill switch. `es-fleet start --foreground` runs the loop (`--repo`,
`--es-bin`, `--interval-ms`). Starting always asks for confirmation;
a background start spawns a detached child over an IPC channel with a
one-time start grant, waits for its pidfile-written report, and prints
the child's error otherwise — a TTY-less child without the grant exits
non-zero. The grant is a plain IPC message, not a secret: it proves only
that the spawner opened an IPC channel, so any process able to spawn
`bin.js` that way can start the fleet without the confirmation. It is
defense in depth, like the TTY check before it (which `script` could fake).
The boundaries that hold are the human-only shell policy on `es-fleet`
(`trust/policy.ts`) and the `ES_SANDBOX` refusal in `assertHumanStart`.
`es-fleet status --token` (human-only, needs a TTY) prints the
bus bearer token.
## Telemetry bus and watch (#126)
- The daemon serves a read-only bus on `127.0.0.1:<port>` (random free port
recorded in `telemetry.json`; bearer token in `token`, 0600, readable only
by the human outside any sandbox):
- `POST /fleet/rpc`: strict-schema JSON-RPC — `fleet.status`,
`fleet.task {id}`, `fleet.task.liveness {id}` (per-task run liveness:
headline, stage, seats, research progress), `fleet.pending`,
`fleet.events {since}` (monotonic
backlog replay over the persisted `events.jsonl`).
- `GET /fleet/ws`: WebSocket stream of `changed` and `liveness` events,
coalesced to at most one emit per channel per second (notifySoon),
dropping to the latest per taskId on bursts.
- Fail-closed binding: localhost only, `Authorization: Bearer` (401),
`Host: 127.0.0.1|localhost:<port>` (403, DNS-rebinding defense), `Origin`
checked on WS upgrades (403). Every method is read-only (the suite hashes
state files around calls). Payloads are scrubbed (secret-named keys and
known secret values become `[redacted]`; credentials never enter payloads).
- `es-fleet watch` polls the bus and redraws a terminal dashboard (headline
spend, task table, pending approvals, recent events); `q` quits, needs a TTY.
## Web control plane (#129)
- `packages/web` (SolidJS + Vite, private, never published; ADR 0003) builds
to `packages/web/dist/`, which the daemon serves from the bus port at `/`.
- `es-fleet web` (human-only, refused under `ES_SANDBOX`) mints a single-use
ticket (32 random bytes, TTL 120 s, kept as a file under the masked state
dir) and prints a one-time URL `http://127.0.0.1:<port>/?t=<ticket>`. The
first load redeems it for an `HttpOnly; SameSite=Strict` session cookie
(12 h); the ticket never redeems twice — the claim is an atomic rename, so
even concurrent loads grant exactly one session — and expires after 120 s,
so browser-history or proxy retention of the URL is harmless.
- Fail-closed: anonymous loads are 401, bad tickets 403, wrong Host 403, a
non-loopback Origin 403, traversal outside the web root 404. The session
cookie also authorizes read-only RPC and WS, but only with a loopback
Origin present (bearer calls are unaffected). Every web response carries a
strict CSP (`default-src 'self'`, no inline scripts, `frame-ancestors
'none'`), `X-Content-Type-Options: nosniff` and `Referrer-Policy:
no-referrer`.
## Fleet dashboard (#130)
- Hash-routed (`#/` overview, `#/task/:id`), built from the read-only bus
only: `fleet.status`, `fleet.task`, and the WS `changed`/`liveness`
stream. There are no mutation paths in the dashboard code (audited per
ticket: the only requests are those two RPC methods plus WS subscribe).
- The overview draws the task DAG (columns by depth, edges from each task's
`deps`, served by the bus since #130), a task table, fleet spend against
the ceiling, and the pending-approvals list linking to each task page.
Status is always words beside colour (1.3 vocabulary: running→working,
waiting-human→waiting on you, etc.), and every task is a link, so the UI
stays navigable by keyboard and readable without colour.
- Liveness is per task: `fleet.task.liveness {id}` serves the run's
headline, stage, seat lines and research progress (`TaskLiveness`, a
`import type`-only payload for the web dashboard), computed in fleet from
the task worktree's factory state with core's `factory/liveness.ts`.
Unknown tasks error; every call leaves all files untouched.
- Live updates: WS events refetch the snapshot; a reconnect refetches too.
No data for 5 s shows a stale banner (`role="status"`).
- Tests run with `bun --conditions=browser test packages/web`: the web
suite renders with the client Solid build, against fixture
data plus a real TelemetryServer for the WS integration test.
Test-only exception: `packages/web/test/bus.contract.test.ts` runtime-imports
`TelemetryServer`/`fleetPaths` for that WS contract test; shipped `src/`
code uses `import type` only.
## Browser approvals (#131, ADR 0002 option b)
- Hash-routed (`#/approvals` list, `#/approve/<task>/<stage>` page).
`POST /fleet/approve/preview {runId, stage}` (bearer or session) returns
the subject summary, files and hashes plus a single-use ticket (TTL
120 s) bound to `hashJson(subject)` and a session CSRF token;
`POST /fleet/approve {ticket, passphrase}` requires the session cookie,
a loopback Origin and the CSRF header, then re-derives the subject,
refuses a mismatch, unlocks the sealed key in-process, calls
`approveStage` with `channel: "web"` and destroys the signer.
- Fail-closed: bearer-only, missing CSRF/Origin and foreign origins are
refused; tickets redeem once; changed artifacts refuse with 409; five
wrong passphrases lock the surface (core limiter, web-scoped state) with
an audited `approval.lockout`. The passphrase travels only over
loopback, lives in one page-local variable, and never reaches logs,
events, responses or state files (probed in tests).
- The page holds the passphrase in a masked, non-reactive input cleared
on submit. Trust, waive, resume, key seal, config set and export stay
terminal-only (ADR 0002 I7) — this ticket adds no path for them.
## Config editor, preview-only (#132)
- Hash-routed (`#/config`), two tabs: project config
(`.factory/config.json`) and gates (`.factory/gates.json`). The page
shows the current layers with their file hashes and the drift state, an
editor, and — after Preview — strict-schema errors inline or the exact
diff (old hash → new hash, changed keys) plus the exact terminal commands
(`es config set <key> <json>` per changed config key; save +
`es rebaseline` for gates).
- Two read-only bus methods back it: `fleet.config.get {}` (current
layers, hashes, drift, forbidden keys) and `fleet.config.plan {file,
content}` (validates with the same `EsConfigSchema`/`GatesConfigSchema`
the terminal path uses; the previewed hash is the sha256 of the exact
bytes `writeJson` would emit). Both leave every file untouched (hashed
around the calls in tests).
- Preview-only is enforced, not promised: the bus has no
`fleet.config.apply` (unknown-method), the UI has no apply button, and
the editor says so on the page. Browser apply stays terminal-only per
ADR 0002 I7 until the user explicitly extends it (after #131) — tracked
as a follow-up issue, not this ticket.
- Note: `docs/CONFIG.md` is generated from the config schema, so the
editor is documented here instead of there (hand-editing generated docs
fails `docs:check`).
## Evidence explorer (#133)
- Hash-routed (`#/evidence/<task>` from the task page): claim/source
graph (coloured by tag, dashed when not LIVE), tag/status/tier
filters, inspector (statement, rank explanation, retractions, history)
and a source viewer that highlights verified quote ranges with `<mark>`
elements built from text nodes — hostile source text is never parsed
as HTML. Seal status (sealed / unsealed / invalid / unknown) shows on
every source.
- Read-only bus methods: `evidence.claims {runId, filter}`,
`evidence.claim {runId, id}` (quotes with original-text ranges from
core's `locateQuote`, retractions, rank), `evidence.source {runId,
sha}` (capped text, meta, seal) and `evidence.export {runId,
markdown|html}` (the #112 `renderResearchRun` renderers). Lists and
texts are capped; every call leaves all files untouched (hashed in
tests). Editing claims and recording retractions stay terminal-only.
- Quote ranges come from `locateQuote` (core): `verifyQuote` decides,
an index map through the same normalisation locates each fragment,
and every range self-checks back to its fragment (a divergence yields
no highlight, never a wrong one).
## Integration suite (#127)
`packages/fleet/test/integration.test.ts` is merge-blocking (runs in
`bun run check`, ~10 s, deterministic — 5/5 green): one booted testkit host
is the fleet repo, and scripted seat turns (fake model) do real work in real
isolated worktrees under the real daemon loop, landing and bus.
- Case 1: A, then B and C in parallel — start/finish marks prove ordering
and overlap; branch trees prove output ownership and that `.factory/` run
state never leaks across branches (it would collide on landing); the
integration branch collects every output; `main` never moves.
- Case 2: error/retry/cancel/conflict — `failOnce`+`retry(1)` runs twice
then succeeds; terminal failure cancels dependents; an add/add conflict
reopens the loser as `waiting-human` (landing conflicts are the one
transition allowed out of `done`) while the tip stays stable.
- Case 3: mid-run `shutdown()` SIGTERMs the worker (exit signal maps to
`cancelled`/resumable), the abandoned branch is salvaged, `gc` is clean.
- Case 4: gates fail — the task is marked `failed` with the gate reason,
the integration tip never moves, `main` never moves.
- Break probes: a shared worktree and a pre-dep start both fail the
ownership/ordering checks, proving the suite bites.
+78
View File
@@ -0,0 +1,78 @@
# Headless runs (`es factory run --headless`)
A headless run drives one factory seat through a harness CLI (`opencode
run`, later `claude -p` / `pi -p`) until the job finishes, the run halts,
progress stalls, the turn cap is reached, a turn times out, or a signal
cancels it. It never answers human checkpoints: it stops and reports when an
approval is pending, the grill needs a human, or the run halts. Launching
one stays human-only (spend-bearing, like audit/scout runs).
## Human output
Without flags the run prints one JSON object per line (see `presentEvent`
in `packages/cli/src/headless.ts`): lifecycle events always, one compact
line per tool call and the assistant's text at the default `info` level,
raw harness events with long strings clipped at `debug`, lifecycle and
progress only at `quiet`. Per-turn metrics print at `debug` only — never at
`info`/`quiet`, so the default output is unchanged by them; they always go
to the event stream below.
## Machine-readable event stream (`--events jsonl`)
`--events jsonl` replaces the human lines with one versioned envelope per
line on stdout:
```json
{"v":1,"at":"2026-10-09T12:00:00.000Z","runId":"run-20261009-120000-abcd","kind":"turn-metrics","event":{"type":"turn-metrics","turn":3,"costUSD":0.04,"tools":[{"name":"read","ok":true}]}}
```
- `v` is the envelope version (1; bumped on any incompatible change).
- `kind` mirrors the event's `type`: `start`, `turn`, `driver`,
`progress`, `turn-metrics`, `waiting`, `halted`, `stalled`, `turn-cap`,
`cancelled`, `done`, `error`.
- `turn-metrics` follows every completed turn: `turn`, `costUSD` (the
sealed run-state spend delta across the turn), opportunistic
`tokensIn`/`tokensOut` (only when the driver reports them), and `tools`
(`name`, `ok`, optional `ms`, optional `seat` — duration and seat only
when the driver reports them).
- `--events-file <path>` writes the envelopes to the file instead, keeping
the human lines on stdout.
The envelope's type and Zod schema are exported from the CLI package for
external consumers: `JsonlEnvelope`, `HeadlessJsonlSchema`, `toJsonl`
(`packages/cli/src/headless.ts`). The fleet does not import that schema:
it parses each line with `JSON.parse`, keeps only envelopes with a string
`kind` and an object `event`, and folds them in `foldWorkerEvent`
(`packages/fleet/src/worker.ts`), reading `costUSD`/`tokensIn`/`tokensOut`
as numbers.
## Cancellation and turn timeouts
- SIGINT/SIGTERM abort the current turn (the child harness is killed through
the driver's abort signal), the run emits `cancelled` and exits **130**.
No halt is written and no STOP file is created: re-running resumes where
it stopped.
- `--turn-timeout <seconds>` aborts a turn that runs longer and reports it
as an `error` with reason `turn-timeout` (the run ends; the state is
untouched).
## Worktrees (`--cwd`)
`--cwd <dir>` points the run at a git worktree. Two refusals (exit 2):
- inside another active run's `.factory/worktrees/` (a seat worktree is not
a run root);
- a checkout with a detached HEAD (a run must be attributable to a branch).
The `--cwd` path is the run's root, so it is recorded in the per-user run
index (`es runs`) at headless start.
## Sample transcript
```
{"v":1,"at":"…","runId":"run-7","kind":"start","event":{"type":"start","runId":"run-7","stage":"RESEARCH","driver":"opencode","monitor":"…"}}
{"v":1,"at":"…","runId":"run-7","kind":"turn","event":{"type":"turn","n":1,"stage":"RESEARCH","activePhase":"p2"}}
{"v":1,"at":"…","runId":"run-7","kind":"driver","event":{"type":"driver","event":{"type":"tool_use","part":{"tool":"read","state":{"status":"completed","input":{"path":"src/a.ts"}}}}}}
{"v":1,"at":"…","runId":"run-7","kind":"turn-metrics","event":{"type":"turn-metrics","turn":1,"costUSD":0.04,"tools":[{"name":"read","ok":true}]}}
{"v":1,"at":"…","runId":"run-7","kind":"cancelled","event":{"type":"cancelled","reason":"cancelled by signal; the run state is untouched, re-run to resume"}}
```
+17 -1
View File
@@ -6,11 +6,12 @@ Do not edit by hand: run `bun run docs:gen` after changing a schema (CI runs `bu
| schema | file |
| --- | --- |
| acceptance-criterion | `schemas/acceptance-criterion.schema.json` |
| approval-channel | `schemas/approval-channel.schema.json` |
| approval-stage | `schemas/approval-stage.schema.json` |
| approval-subject | `schemas/approval-subject.schema.json` |
| approval | `schemas/approval.schema.json` |
| audit-chain-telemetry | `schemas/audit-chain-telemetry.schema.json` |
| audit-entry | `schemas/audit-entry.schema.json` |
| audit-finding-count | `schemas/audit-finding-count.schema.json` |
| audit-finding | `schemas/audit-finding.schema.json` |
| audit-record | `schemas/audit-record.schema.json` |
| audit-severity | `schemas/audit-severity.schema.json` |
@@ -22,11 +23,15 @@ Do not edit by hand: run `bun run docs:gen` after changing a schema (CI runs `bu
| brainstorm-score | `schemas/brainstorm-score.schema.json` |
| brainstorm-shortlist-entry | `schemas/brainstorm-shortlist-entry.schema.json` |
| brief | `schemas/brief.schema.json` |
| chain-trail-entry | `schemas/chain-trail-entry.schema.json` |
| channel | `schemas/channel.schema.json` |
| check-status | `schemas/check-status.schema.json` |
| claim-location | `schemas/claim-location.schema.json` |
| claim-status | `schemas/claim-status.schema.json` |
| claim-tag | `schemas/claim-tag.schema.json` |
| claim | `schemas/claim.schema.json` |
| cluster-kind | `schemas/cluster-kind.schema.json` |
| cluster | `schemas/cluster.schema.json` |
| command-check | `schemas/command-check.schema.json` |
| design-answers | `schemas/design-answers.schema.json` |
| design-artifacts | `schemas/design-artifacts.schema.json` |
@@ -35,10 +40,13 @@ Do not edit by hand: run `bun run docs:gen` after changing a schema (CI runs `bu
| dossier-verdict | `schemas/dossier-verdict.schema.json` |
| dossier | `schemas/dossier.schema.json` |
| es-config | `schemas/es-config.schema.json` |
| eval-telemetry | `schemas/eval-telemetry.schema.json` |
| failure-entry | `schemas/failure-entry.schema.json` |
| frontier-node | `schemas/frontier-node.schema.json` |
| frontier | `schemas/frontier.schema.json` |
| gate-budgets | `schemas/gate-budgets.schema.json` |
| gate-finding | `schemas/gate-finding.schema.json` |
| gate-rejection | `schemas/gate-rejection.schema.json` |
| gate-security | `schemas/gate-security.schema.json` |
| gates-config | `schemas/gates-config.schema.json` |
| goal-frontmatter | `schemas/goal-frontmatter.schema.json` |
@@ -50,8 +58,12 @@ Do not edit by hand: run `bun run docs:gen` after changing a schema (CI runs `bu
| harvest-row | `schemas/harvest-row.schema.json` |
| harvest-verdict | `schemas/harvest-verdict.schema.json` |
| license-finding | `schemas/license-finding.schema.json` |
| model-ref | `schemas/model-ref.schema.json` |
| parser-id | `schemas/parser-id.schema.json` |
| prior-art-search | `schemas/prior-art-search.schema.json` |
| probe-row | `schemas/probe-row.schema.json` |
| proposal-kind | `schemas/proposal-kind.schema.json` |
| proposal | `schemas/proposal.schema.json` |
| provenance | `schemas/provenance.schema.json` |
| queereye-tokens | `schemas/queereye-tokens.schema.json` |
| record-signature | `schemas/record-signature.schema.json` |
@@ -61,6 +73,9 @@ Do not edit by hand: run `bun run docs:gen` after changing a schema (CI runs `bu
| retraction | `schemas/retraction.schema.json` |
| roadmap-phase | `schemas/roadmap-phase.schema.json` |
| roadmap | `schemas/roadmap.schema.json` |
| run-phase-telemetry | `schemas/run-phase-telemetry.schema.json` |
| run-state-seal | `schemas/run-state-seal.schema.json` |
| run-telemetry | `schemas/run-telemetry.schema.json` |
| scout-advisory | `schemas/scout-advisory.schema.json` |
| scout-assessment | `schemas/scout-assessment.schema.json` |
| scout-plan | `schemas/scout-plan.schema.json` |
@@ -68,5 +83,6 @@ Do not edit by hand: run `bun run docs:gen` after changing a schema (CI runs `bu
| scout-result | `schemas/scout-result.schema.json` |
| scout-verdict | `schemas/scout-verdict.schema.json` |
| spend-ceiling | `schemas/spend-ceiling.schema.json` |
| telemetry | `schemas/telemetry.schema.json` |
| waiver | `schemas/waiver.schema.json` |
| witness | `schemas/witness.schema.json` |
+58
View File
@@ -0,0 +1,58 @@
# Self-dogfood runbook: the factory builds this repo
The strongest proof the factory works is that it improves itself: take an
issue from this repo, build it in an isolated worktree under the full gate
suite, and open a draft PR a human reviews. This runbook is that loop.
## Prerequisites
- A machine where bubblewrap works (`bwrap --version`; some local
containers have a broken overlay — CI has bwrap 0.9). Without it the
programmer seat refuses to run.
- `gh` authenticated (`gh auth status`) — the release stage opens a draft
PR through it.
- A sealed human key (`es key seal` once); approvals, trust and resume all
need the passphrase.
- Work on `rewrite`, with a clean tree.
## The flow
1. **Seed the run** (human-only; agents cannot write factory control files):
`es factory init --preset self-dogfood --issue <n>`.
This writes `.factory/config.json` (`audit.phase: required`), the
self-dogfood `.factory/gates.json` (`bun install --frozen-lockfile`,
`bun run check`, `bun run docs:check`), a `.factory/roadmap.json` seed
pinning `baseBranch: rewrite`, and the issue body as
`.factory/notes/idea-<n>.md`.
2. **Trust the gates**: `es trust` (human-only). The gate commands run
project code; review the hashes before trusting.
3. **Grill**: `/grill` (or `es factory begin` plus the grill flow) with a
**$15 spend ceiling** (the preset default; raise deliberately). Keep
`baseBranch: rewrite` when the grill rewrites the roadmap — the factory
never pushes `main` or `rewrite` itself.
4. **Approve the frontier and the spec** (`es approve frontier`, then
`es approve spec`) — on any Phase 2/4 surface: terminal, TUI or browser.
5. **Build ⇄ QA** runs in the phase worktree under bwrap; the runtime cap
is mandatory and `.factory/STOP` halts the run at its next step.
6. **Release**: the merge gate runs in full; on green the factory pushes
only its run branch and opens a **draft PR against `rewrite`**.
A human reviews and merges. Auto-merge never happens.
## Reviewing the PR
- Check the base is `rewrite` and the head is a `factory/…` branch.
- Read the gate logs under `.factory/runs/<run>/gates/` and the spend
against the $15 ceiling.
- Merge only when every check — including CodeRabbit and SonarCloud —
is green.
## Aborting
Write `.factory/STOP` (or `es factory stop <reason>`); the run halts at
its next step. `es factory resume` clears the halt (human-only).
## Validation sessions
The first human-run session on a small, low-risk issue is recorded on
#137: run id, draft PR link, spend and gate results. Later sessions go
on their own issues.
+26
View File
@@ -0,0 +1,26 @@
# One repository, one npm package per surface
Epistemic Swarm stays a single repository. Core, the CLI, the OpenCode plugin and the testkit live in it, and so will future surfaces: host adapters, `packages/fleet`, and `packages/web` (private, never published to npm). Separation happens at the npm-package level, not the repo level.
The reason is that the trust contract crosses package lines:
- core enforces policy, sidecars and seals;
- the plugin wires that enforcement into the host's permission hook;
- the CLI holds the human side (approve, key, reseal);
- the plugin writes the on-disk state, and the CLI reads and re-signs it, byte for byte.
These pieces must change together. That is why core, CLI and plugin release in lockstep, and why pipeline A tests them on the real host in one merge-blocking `bun run check`. #88 shows the risk: the CLI's argument parser and core's human-only matcher drifted apart, and in one repo it was caught and fixed in a single commit with tests beside both.
**Considered options.**
- *A repo per package.* This would need a compatibility matrix for the shared state format, and cross-repo integration tests that can't block the merge that broke them.
- *Separate plugins for brainstorm, harvest and deep research.* Each needs core's evidence cache and the plugin's permission perimeter. A second plugin would need its own perimeter, and host plugin load order is not a security boundary.
**Consequences.**
- Core must not import the CLI or the plugin, and the CLI must not import the plugin. `scripts/deps.ts` enforces this in `bun run check`.
- The workspace is one list: `scripts/packages.ts` reads `packages/*/package.json` (release order from each manifest's `esRelease.order`). The typecheck loop, `pack-smoke.sh` and `publish.yml` all take their lists from it; a new package adds its manifest (plus `esRelease.order` when it publishes) and a `BOUNDARIES` entry in `scripts/deps.ts`, or the gate fails.
- New packages get path-filtered CI jobs so core's CI stays fast: a separate workflow with `paths:` filters on the package's directories (or a SHA-pinned paths-filter action), while the `check` job stays unfiltered and keeps running on every push and PR. They version on their own or stay private; only the trust trio (core, CLI, plugin) releases in lockstep.
- Scraper-Swarm stays its own repository. It is a deployed service on its own toolchain (pnpm plus Python), so core integrates with it over MCP JSON-RPC, with recorded-fixture contract tests here.
**Revisit when** a component:
- gets its own language, toolchain or deploy secrets;
- gains an outside community that needs its own tracker or permissions;
- speaks to the rest over a stable, versioned protocol that no longer needs lockstep releases.
+228
View File
@@ -0,0 +1,228 @@
# ADR 0002: Approvals on every surface (TUI, CLI, browser)
- **Status:** Accepted (2026-10-09)
- **Date:** 2026-10-09
- **Epic:** #92 · **Milestone:** Phase 2 · Approvals everywhere · 1.3.0
- **Ticket:** #116 (threat model and checklist included; no separate file)
- **Supersedes:** decision 20 on #35, for approvals only.
## Context
Decision 20 on #35 kept passphrase entry in a terminal: the OpenCode TUI
previewed an approval and then told the human to run `es approve <stage>` at a
terminal, where `confirmHuman` reads the passphrase with echo off and unlocks
the sealed Ed25519 human key. Terminal-only existed for one reason: the
passphrase is the only thing an agent must never reach, and a terminal prompt
with echo off was the only input the project trusted.
On 2026-10-08 the user reversed that decision **for approvals only**:
"Approve should be in the OpenCode interface, CLI, and web browser."
Trust, waivers, resume, `key seal`, `config set` and research export stay
terminal-only.
This ADR records the new decision, the threat model, the normative invariants
every surface must satisfy, the inventory of terminal-only enforcement points
with their dispositions, and the adversarial-review checklist that gates the
TUI build (#119) and the browser build (#131).
The key security observation, stated in the epic: the passphrase protects
approvals, not the terminal. The plugin's server process already runs
unsandboxed on the human's machine and can read the sealed key file
(`ensureEngineKey` runs at setup). What was never exposed — and must stay
unexposed — is the passphrase itself, plus the act of signing without a live
human typing it.
Prior art consulted (clean-room: concepts only, no code taken): the
single-use ticket hashes bound to subject hashes already in this repo's RPC
layer, the durable-approvals pattern of single-use TTL-bound tickets, and the
loopback-only plus origin-check pattern for local approval servers.
## Decision
Frontier and spec approvals work on three surfaces — the CLI (as today), the
OpenCode TUI (#119), and the browser (#131, Phase 4) — under invariants
I1–I8 below. Everything else human-only stays terminal-only (I7). The shared
approval side effects move into one core service, `approveStage` (#117), so no
surface copies the record → clear-pending → begin-research sequence.
Browser key handling follows option (b): the sealed key never leaves the
machine's state dir; the passphrase travels only over loopback to the local
server (confirmed by user 2026-10-09).
## Options considered
- **(a) Terminal-only (status quo).** Keep decision 20. Rejected: the user
explicitly reversed it for approvals, and the TUI preview-then-terminal
round trip is the worst of both worlds — the human reviews in one place
and signs in another.
- **(b) Server-side unlock in the plugin or fleet process (recommended).**
The passphrase is typed into a human-facing input on the machine (TUI
dialog, localhost page) and travels at most over loopback to the local
server process, which unlocks the sealed key, signs, and zeroizes. The
sealed key file never leaves the state dir and the private key never
crosses a network boundary. The existing server already reads the state
dir, so this grants it no new reach — only the passphrase, transiently.
- **(c) Client-side unlock in the browser, sealed key blob sent to the page.**
The page fetches the sealed blob, decrypts with a passphrase typed in the
page, and signs there (or returns the signature). Rejected as the default:
it moves the sealed blob across an HTTP boundary into a JavaScript context
shared with page scripts, widening what a compromised dependency in
`packages/web` (actor 5) can steal from "transient passphrase" to "the
sealed key itself, for offline attack at leisure". scrypt slows guessing
but a stolen blob can be attacked forever, on any hardware.
## Threat model
Actors and what they can reach, with mitigations. "Agent with shell" means a
prompt-injected agent acting through a seat or user-agent shell.
1. **A prompt-injected agent with a shell (sandboxed; unsandboxed as the
fallback).** Can run anything its sandbox allows, including driving a PTY.
Mitigations: the passphrase is never echoed and never lands in files the
agent can read (I1); signing needs the live-typed passphrase, which a PTY
recording cannot recover — watching dots is not the passphrase; mutating
approval endpoints are unreachable from agent contexts (I6); attempts are
throttled (I4); `HUMAN_VERBS` keeps `approve` denied in every agent shell.
2. **An agent holding the OpenCode server password.** Can call any RPC the
server exposes. Mitigations: there is no approve/trust/resume RPC and
there never will be — the TUI signs in its own process through core, not
over RPC (#119 design), so the #49 property ("no mutating approval RPC")
still holds; preview tickets are single-use, TTL-bound (≤ 120 s) and tied
to `hashJson(subject)`, and redeeming one without the dialog path buys
nothing (I3, I6).
3. **A malicious web page in the same browser (CSRF / DNS rebinding against
a localhost server).** Can make the browser send requests to loopback.
Mitigations for the browser path (#131): the local server binds
loopback-only, checks `Host` and `Origin`, and requires a one-time URL
token issued by a human-only verb; it refuses to run under `ES_SANDBOX`
(I6). Rebinding a DNS name to 127.0.0.1 still fails the `Host`/`Origin`
checks.
4. **A shoulder-surfer.** Sees the screen. Mitigations: masked input shows
only bullets (I1); the CLI equivalent is echo-off input.
5. **A compromised npm dependency in `packages/web`.** Runs script in the
approval page's context. Mitigations: under option (b) the page sees only
the passphrase transiently (still bad — hence the recommendation is
necessary, not sufficient), and never the sealed blob or the private key
(I2); under the rejected option (c) it would see the sealed blob. The
browser ticket (#131) must additionally scope page scripts (lockfile,
SRI/CSP) — listed in the checklist.
## Invariants (normative)
- **I1** The passphrase is collected only by a human-facing input that masks
keystrokes, and is held only in a local variable for the unlock. It never
enters reactive state, logs, telemetry, RPC events or model context.
- **I2** The private key is decrypted only in the process that signs, and
zeroized after signing (best effort in JS: overwrite the Buffer). It never
crosses a network boundary.
- **I3** Every surface signs only what it previewed. A single-use ticket
(TTL ≤ 120 s) binds the preview to `hashJson(subject)`, and signing
re-derives the subject and refuses a mismatch.
- **I4** Attempts are limited: at most N wrong passphrases per M minutes per
surface (recommended 5 per 10 min), then a lockout and an audit entry.
- **I5** The channel is recorded (`cli`, `tui` or `web`) and audited.
- **I6** Mutating approval endpoints are unreachable from agent contexts: no
agent tool and no MCP tool; the TUI path requires the TUI process's dialog;
the web path requires a localhost-only server started by a human-only verb,
which refuses under `ES_SANDBOX`, checks origin and Host, and needs a
one-time URL token.
- **I7** Trust, waive, resume, key seal, config set and export stay
terminal-only.
- **I8** Browser key handling follows option (b): the sealed key never leaves
the machine's state dir; the passphrase travels only over loopback to the
local server. **Confirmed by user (2026-10-09).**
## Inventory of terminal-only enforcement points
"Keep" means unchanged. "Change" names the ticket that changes it. Verified
against the tree on 2026-10-09 (branch `integration/phase-2`, at Phase 1
HEAD `477d607`); line numbers are approximate.
| # | Point | Disposition |
|---|---|---|
| 1 | `packages/cli/src/tty.ts` (`NotInteractive`, echo-off `readSecret`, `confirmHuman`) | Keep: the CLI path still needs a TTY and masked input. |
| 2 | `packages/cli/src/key.ts` (`es key seal` messaging) | Keep: key seal stays terminal-only (I7). |
| 3 | `packages/cli/src/main.ts` (TTY refusal, exit codes) | Keep: CLI human verbs still refuse without a TTY. |
| 4 | `packages/opencode/src/rpc-def.ts` header (no approve/trust/resume RPC) | Change wording in #119: approvals join the TUI but still never cross RPC; the no-mutating-RPC property stays and its test stays. |
| 5 | `packages/opencode/src/rpc.ts` header + `issue`/`redeem` (120 s TTL, `hashJson(subject)` binding; only `lspInstall` redeems) | Keep the mechanism; reword the header in #119. #119 adds no approve RPC. |
| 6 | `packages/opencode/src/tui.tsx` header + approve alert (`:72`) | Change in #119: approve completes in the TUI via the masked dialog and `approveStage`. |
| 7 | `packages/opencode/src/tui.tsx` trust (`:82`) and resume (`:103`) alerts | Keep: trust and resume stay terminal-only (I7). |
| 8 | `HUMAN_VERBS` + `HUMAN_ONLY` (`packages/core/src/trust/policy.ts`) | Keep: `approve` stays a human-only shell verb; the TUI path does not go through a shell. |
| 9 | `packages/core/src/approval/record.ts` header ("Only the CLI calls recordApproval") | Change in #117: the core service owns the sequence; every surface calls it. |
| 10 | `verifyApproval` / `signatureProblem` messages (`record.ts`, `keystore.ts`) | Change in #119 where they say "at a terminal" for re-approval; keep the `es key seal` wording (I7). |
| 11 | `packages/core/src/factory/liveness.ts` waiting lines | Change in #119 where they say approvals happen "in a terminal". |
| 12 | `packages/core/src/ops/tools.ts` (`esTools` waiting line, `es_request_approval` reply) | Keep: both already name `/es-approve` first; re-check in #119. |
| 13 | `packages/cli/src/mcp.ts` header (no approve/trust/waive/resume tool) | Keep (I6). |
| 14 | `packages/opencode/src/server.ts` setup + research/audit/resume messages | Keep: research, audit and resume paths are out of scope. |
| 15 | `packages/opencode/src/panels.tsx` pending-approvals footer | Change in #119: approve in the TUI, not only in a terminal. |
| 16 | Prompts `grill.md`, `factory.md`, skill `factory/SKILL.md` | Change in #119: both already name `/es-approve`; drop "terminal-only" framing for approvals, keep it for trust/waive/resume. |
| 17 | Tests `plugin.test.ts` #49 block (no approve RPC) | Keep: the property still holds after #119. |
| 18 | Tests `plugin.test.ts` + `fires-gates.test.ts` copied approve steps | Change in #117: call `approveStage` instead. |
| 19 | Tests `tui.test.ts` (preview → terminal alert) | Change in #119: preview → masked dialog → record. |
| 20 | Tests `cli.test.ts` (TTY + passphrase approval tests) | Keep: CLI behaviour is unchanged. |
| 21 | Tests `policy.test.ts` (human-only denials) | Keep. |
| 22 | Docs `AGENTS.md` invariant 1, `SYSTEM_ARCHITECTURE.md` §4 item 1 | Change in #116 (this ticket): reference this ADR; behaviour lands in #119. |
| 23 | Docs `README.md`, `docs/DOGFOOD.md` approval steps | Change in #119. |
| 24 | `CHANGELOG.md` 1.1.1 entries | Keep: history. |
| 25 | Waiver channel copies (`schema/waiver.ts`, `gates/waivers.ts`) | Change in #117: one shared channel schema incl. `"web"`. |
## Adversarial-review checklist (gates #119 and #131)
Each probe must be run against the built surface and its result posted on the
ticket before merge. All probes apply to both surfaces unless marked.
1. Approve from an agent shell (sandboxed seat and plain user agent): refused,
nothing signed, nothing recorded.
2. Approve through `es mcp` (pinned adapter identity): no approve tool
exists; the call fails.
3. Approve through an RPC call without the dialog: no mutating approval RPC
exists (#49 test still green); a bare preview token redeems nothing.
4. Replay a ticket: redeeming the same preview token twice fails; an expired
(> 120 s) ticket fails.
5. Change an artifact after the preview: signing re-derives the subject and
refuses on hash mismatch (I3).
6. Brute-force the passphrase: 5 wrong attempts per 10 min lock out the
surface with an audit entry (I4); scrypt cost unchanged.
7. Read the passphrase from logs, state, telemetry or UI snapshots: the
passphrase appears nowhere but the masked input's local variable (I1).
Grep the state dir, logs and test snapshots for the probe passphrase.
8. CSRF against the web server (#131 only): a cross-origin form/POST without
the one-time URL token is refused; wrong `Origin`/`Host` refused.
9. DNS rebinding (#131 only): a request resolving to loopback under a foreign
`Host` is refused.
10. Remote-attach TUI (#119 only): a TUI that cannot see the project root and
state dir keeps the "approve in a terminal" alert instead of signing
anywhere else.
## Consequences
- `ApprovalChannelSchema` gains `"tui"` usage (already defined) and `"web"`
(#117); every record carries its channel (I5).
- The TUI plugin links core's approval service directly and signs in-process;
no new RPC, no passphrase on the wire anywhere except loopback for the
browser path.
- Attempt-limit state lives per surface (a small file in the state dir for
the TUI/CLI; server memory plus audit for the web path).
- Docs and messages stop saying approvals are terminal-only, while trust,
waive, resume, key seal, config set and export keep saying it.
## Revisit when
- The host gains a first-party masked input (the #118 spike's fallback
analysis may already obsolete the custom component).
- WebAuthn / platform-keystore signing becomes available in the supported
hosts (could remove passphrase-over-loopback for the browser path).
- A second approver or quorum is requested (record schema v3; new ADR).
## To accept
The author recommends I8 option (b) for the reasons under "Options
considered". **Before marking this ADR accepted, the user must confirm the
I8 choice; quote the confirmation here and flip the status to Accepted.**
Until then, #117 and #118 proceed (they need only I1–I7), while #119 and
#131 must not merge.
**Status note (2026-10-09):** a Stage-3 cascade step briefly marked this
ADR Accepted without the required user I8 confirmation; reverted to
Proposed. The merging human flips to Accepted only after quoting the
user's verbatim I8 option-(b) confirmation here.
+60
View File
@@ -0,0 +1,60 @@
# ADR 0003: Web control plane framework (SolidJS)
- **Status:** Accepted (SolidJS, per the Phase 4 plan direction; the human re-confirms at the #134 release review).
- **Date:** 2026-10-09
- **Epic:** #94 · **Milestone:** Phase 4 · Web control plane · 1.5.0
- **Ticket:** #129 (scaffold; features follow in #130–#133)
## Context
`packages/web` is the fourth surface: the fleet dashboard, browser approvals,
config editor and evidence explorer, served by the fleet daemon on loopback
behind a single-use ticket exchange (#129). The build must be fully local
(no runtime CDN — the daemon's CSP is `script-src 'self'`), small, and
consistent with the surfaces that already exist. The TUI (`packages/opencode`)
renders with Solid (`solid-js` 1.9.15 plus `@opentui/solid`).
Prior art consulted (clean-room: concepts only, no code taken): the
single-use web-token plus loopback-origin pattern of local-first web UIs,
the one-time-URL session bootstrap of device-pairing flows, and the DAG and
evidence-graph layouts of the orchestrator UIs named in #130/#131/#133.
## Options
- *React + Vite.* The mature choice for graph work: React Flow is the
reference DAG library #130 and #133 would otherwise need to re-approach.
Cost: a second reactive model in the repo, a larger baseline bundle, and a
new lockfile subtree with no sharing against the TUI.
- *SolidJS + Vite (chosen).* The same signals model the TUI already uses,
the same `solid-js` lockfile entry (1.9.15, shared with the TUI — no new
subtree), and a smaller baseline: the #129 hello page builds to 15 kB of
JS. Cost: thinner off-the-shelf DAG/graph libraries than React Flow.
## Decision
Adopt **SolidJS with Vite** for `packages/web`:
1. The framework shares the TUI's reactive vocabulary (signals, resources),
so dashboard code and TUI panels stay mutually readable.
2. The bundle stays small enough to serve from the daemon without caching
tricks (15 kB hello page, measured in #129).
3. The graph gap is handled by **clean-room SVG rendering** in #130/#133,
not by vendoring a React graph stack: the DAG and claim-graph layouts
are simple node-link views over data the bus already shapes, and the
repo constitution forbids copying either way. If a Solid graph library
is adopted later, it gets its own scout verdict first.
4. No runtime CDN, ever: `vite build` emits `packages/web/dist/`, served
from the workspace path in dev and from an embedded asset map once
fleet publishes.
## Consequences
- `packages/web` is private and never published (ADR 0001); it declares
boundaries in `scripts/deps.ts` (nothing imports web; web imports nothing
at runtime except the fleet API's schema types) and gets a path-filtered
CI job (`.github/workflows/web.yml`).
- Serving stays on the bus port with the #126 token/Host/Origin defences
plus the #129 ticket→cookie exchange and strict CSP.
- **Revisit when** a feature needs a graph interaction (pan/zoom/layout)
that hand-rolled SVG cannot carry cleanly — with a scout verdict before
any new dependency.
+386
View File
@@ -0,0 +1,386 @@
> **Archived 2026-10-08. Superseded by the phase epics on GitHub.** This is the original vision document behind roadmap issues #69–#84. Those issues are closed; the tickets that replaced them were checked against the code and follow ADR 0001 (`docs/adr/0001-monorepo.md`) and the user's 2026-10-08 decisions. Use the epics, not this file, for planning:
>
> | Phase | Epic | Version |
> |---|---|---|
> | 0 · Hardening | [#90](https://github.com/Heretek-AI/IUMBTEMS/issues/90) | 1.1.5 |
> | 1 · Epistemic layer | [#91](https://github.com/Heretek-AI/IUMBTEMS/issues/91) | 1.2.0 |
> | 2 · Approvals everywhere (TUI, CLI, browser) | [#92](https://github.com/Heretek-AI/IUMBTEMS/issues/92) | 1.3.0 |
> | 3 · Fleet | [#93](https://github.com/Heretek-AI/IUMBTEMS/issues/93) | 1.4.0 |
> | 4 · Web control plane | [#94](https://github.com/Heretek-AI/IUMBTEMS/issues/94) | 1.5.0 |
> | 5 · Self-improvement | [#95](https://github.com/Heretek-AI/IUMBTEMS/issues/95) | 1.6.0 |
> | Scraper-Swarm gateway contract v1 | [Heretek-AI/scraper-swarm#3](https://github.com/Heretek-AI/scraper-swarm/issues/3) | — |
>
> Known differences from the text below:
> - Brainstorm, harvest and deep research stay at depth 1. The callers fan out; nothing nests.
> - Approvals (only) move to the TUI and the browser under ADR 0002. Every other human action stays terminal-only.
> - The fire-suite names in §3.6 and §4 don't exist; the merge gate is `bun run check`.
> - Git alternates are unnecessary, because worktrees share the object store.
# IUMBTEMS / Epistemic Swarm: Master Architectural Roadmap
> **Vision:** Transform Epistemic Swarm from a single-session OpenCode build factory into a distributed, multi-seat epistemic intelligence and autonomous software manufacturing fleet — anchored by evidence-first verification, adversarial truth-seeking, and cryptographic human authorization.
---
## 1. Executive Summary & Design Principles
This roadmap expands **IUMBTEMS / Epistemic Swarm** across three evolutionary horizons while strictly preserving its core contract invariants:
1. **Evidence First & Mechanical Integrity:** Every factual claim is witnessed (`[VERIFIED]`, `[INFERRED]`, `[HYPOTHESIS]`, `[NEGATIVE_KNOWLEDGE]`), content-addressed, and HMAC-sealed. Irreversible decisions remain guarded by cryptographic human authorization.
2. **Dual-Mode Ergonomics:** Specialized lateral thinking tools (Brainstorm, Darkharvest, Deep Research) function interchangeably as interactive sparring partners for the human and as typed, callable subagents/tools for automated factory seats.
3. **Adversarial Truth-Seeking:** Facts and hypotheses are not accepted passively; they are stress-tested through thesis/antithesis agent pairs (Alpha/Beta, Auditor Thesis/Antithesis, QA Functional/Adversarial).
4. **Fleet-Scale Orchestration:** Coordinated execution across multiple sessions, git worktrees, and repositories managed by a dedicated daemon (`es-fleet`), monitored via an interactive WebUI control plane.
5. **Bounded Recursive Self-Improvement:** The system mines its own operational telemetry (replans, QA failures, audit catches) to optimize prompts, gate heuristics, and domain packs, while dogfooding feature development against its deterministic fire suites.
---
## 2. System Architecture & Topology
```mermaid
flowchart TD
subgraph Clients["Interaction Surfaces"]
User["Human Operator"]
WebUI["WebUI Control Plane (packages/web)"]
TUI["OpenCode v2 TUI Panels (/es-*)"]
CLI["es CLI & Terminals"]
end
subgraph ControlPlane["Fleet & Control Plane (packages/fleet)"]
FleetDaemon["es-fleet Daemon"]
JobQueue["Task & DAG Orchestrator"]
EventBus["Telemetry & Event Bus"]
KeyBridge["Client-Side Ed25519 Signing Bridge"]
end
subgraph OpenCodeHost["OpenCode v2 Engine"]
OCPlugin["@heretek-ai/epistemic-swarm Plugin"]
Hooks["Hooks: Permission, Shell, Tool, Session"]
CustomRPC["EsRpc (JSON-RPC + SSE)"]
Sandboxes["bwrap Sandboxes (Seat / Programmer / Gate)"]
end
subgraph EpistemicLayer["Epistemic & Lateral Engines"]
Brainstorm["Brainstormer (8 Lenses + Critic)"]
Darkharvest["Harvester (SPDX + Clean-Room Specs)"]
DeepResearch["Adversarial Deep Research Swarm"]
ScraperSwarm["Scraper-Swarm Gateway (MCP / Stealth Scrape)"]
EvidenceCache["HMAC-Sealed Source Cache & Claim Ledger"]
end
subgraph FactoryEngine["Build Factory Core (@heretek-ai/es-core)"]
Machine["State Machine (GRILL ➔ RESEARCH ➔ SPEC ➔ BUILD ⇄ QA ➔ RELEASE)"]
Gates["Mechanical Gates (Format, Lint, Types, Tests, OSV, Budgets)"]
Worktrees["Git Worktree Manager (Phased Branch Isolation)"]
end
subgraph SelfImprovement["Recursive Self-Improvement"]
TelemetryHarvest["Failure & Replan Distillation"]
SelfDogfood["Self-Dogfood Factory (Isolated Worktree)"]
FireSuites["Deterministic Fire Suites & Graders"]
end
User -->|Browser Session| WebUI
User -->|OpenCode Chat & Panels| TUI
User -->|Passphrase CLI| CLI
WebUI <-->|WSS / JSON-RPC| FleetDaemon
CLI <-->|Unix Socket / REST| FleetDaemon
TUI <-->|EsRpc| OCPlugin
FleetDaemon --> JobQueue
FleetDaemon --> EventBus
JobQueue -->|Spawn & Monitor| OpenCodeHost
OCPlugin --> Hooks
OCPlugin --> CustomRPC
Hooks --> Sandboxes
OpenCodeHost --> EpistemicLayer
OpenCodeHost --> FactoryEngine
EpistemicLayer --> ScraperSwarm
EpistemicLayer --> EvidenceCache
EvidenceCache --> Machine
FactoryEngine --> Worktrees
FactoryEngine --> Gates
EventBus --> TelemetryHarvest
TelemetryHarvest --> SelfDogfood
SelfDogfood --> FireSuites
```
---
## 3. Subsystem Breakdown
### 3.1. Subsystem 1: Dual-Mode Epistemic Layer (Brainstorm & Darkharvest)
#### Problem Statement
Currently, `brainstormer` and `harvester` are configured strictly as primary interactive agents spawned via slash commands (`/brainstorm`, `/harvest`). They cannot be directly invoked as structured subagents by the Factory Manager, Grill, or an external conversational orchestrator to dynamically hunt for prior art, benchmark features, or ideate options mid-stream.
#### Architecture & Capabilities
1. **Dual Invocation Contract:**
- **Interactive Mode:** The user launches `/brainstorm` or `/harvest` in OpenCode or the WebUI to engage in open dialogue, steer focus areas, and inspect interim outputs.
- **Subagent / Tool Mode:** Registered as callable subagents (`es-brainstormer`, `es-harvester`) and high-level tools:
- `es_brainstorm_fanout(prompt: string, lenses?: string[], rubric?: string)`: Runs the 8 divergent lenses in parallel, executes the critic deduplication and scoring rubric, writes `.factory/brainstorm/`, and returns structured candidate options to the calling agent.
- `es_harvest_target(targets: string[], objective: string)`: Runs repository teardowns, license scanning via SPDX template matching, generates feature matrices, and writes clean-room specs to `.factory/harvest/`.
2. **Path Scoping & Sandboxing:**
- Retains strict per-seat write scoping: `brainstorm` seats write exclusively to `.factory/brainstorm/notes/`; `harvester` writes exclusively to `.factory/harvest/notes/`.
- Control files and core factory state remain deny-write.
3. **Artifact Integration:**
- Output artifacts (`shortlist.json`, `matrix.json`, `specs/*.md`) are automatically registered into the factory's design tree (`frontier.json`) and spec pipeline.
---
### 3.2. Subsystem 2: Dual-Seat Adversarial Deep Research Swarm & Scraper-Swarm Integration
#### Problem Statement
Factory research (`research-alpha` and `research-beta`) is currently tightly tethered to software development projects and the internal `.factory/` directory structure. Users require a general-purpose, standalone deep research agent capable of executing open-ended, non-code fact-finding missions with high-scale stealth scraping and adversarial rigor.
#### Architecture & Workflow
```mermaid
sequenceDiagram
autonumber
actor User as Operator / Primary Agent
participant Orch as Research Coordinator
participant Alpha as Research Thesis (Alpha)
participant Beta as Research Antithesis (Beta)
participant SS as Heretek Scraper-Swarm (/mcp)
participant Cache as HMAC Content Cache
participant Synth as Epistemic Synthesizer
User->>Orch: Brief ("Investigate market, technology, or legal domain")
Orch->>Orch: Hypothesis & Query Decomposition
par Exploration & Evidence Gathering
Orch->>Alpha: Thesis Query Plan
Alpha->>SS: web_search / stealth_scrape
SS-->>Alpha: Raw Markdown / HTML
Alpha->>Cache: Ingest Source (HMAC-SHA256 sealed)
Alpha->>Alpha: Extract Claims [VERIFIED: <sha256>]
and Counter-Falsification
Orch->>Beta: Antithesis Query Plan (Search for contradictions)
Beta->>SS: web_search / fetch_page
SS-->>Beta: Raw Markdown / HTML
Beta->>Cache: Ingest Source (HMAC-SHA256 sealed)
Beta->>Beta: Extract Counter-Claims / Downgrades
end
Alpha-->>Synth: Thesis Dossier + Evidence Citations
Beta-->>Synth: Antithesis Cross-Examination
Synth->>Synth: Epistemic Audit (Grounding, Conflict Resolution)
Synth-->>User: Standalone Verified Dossier (Markdown / PCRB JSON / PDF)
```
#### Key Components:
1. **Scraper-Swarm Gateway Integration:**
- Direct connection to `/home/john/Projects/scraper-swarm` via its gateway JSON-RPC `/mcp` endpoint.
- Authenticated via scoped agent tokens (`["search", "scrape"]`) issued by `panel-api`.
- Leverages Scraper-Swarm's SSRF protection (`deny_reason_for_url`), Smokescreen outbound proxy, and rate-limiting bucket.
- Built-in fallback: If Scraper-Swarm gateway is offline, gracefully degrades to cached SearXNG/Brave or host websearch.
2. **Claim Ledger & Sealing:**
- All fetched bytes are saved into content-addressed sha256 files sealed with `engine.key` HMAC.
- Every claim in the generated brief must carry an evidence tag: `[VERIFIED: <sha256>]`, `[INFERRED: <parents>]`, `[HYPOTHESIS: <test>]`, or `[NEGATIVE_KNOWLEDGE: <query>]`.
3. **Repository-Free Export:**
- Operates in standalone mode without requiring a git repository or `.factory/` software project.
- Outputs verifiable, standalone dossiers exportable to Markdown, HTML, and PCRB (Proof of Content Research Brief) cryptographic bundles.
---
### 3.3. Subsystem 3: Deep OpenCode v2 Integration Matrix
OpenCode v2 provides an extensive, modular plugin architecture. Epistemic Swarm leverages this surface across both Server (engine) and Client (TUI) domains:
| OpenCode v2 Domain | Hook / API Used | Swarm Enforcement & Purpose |
| :--- | :--- | :--- |
| **`ctx.agent`** | `agent.transform(...)` | Compiles canonical registry into OpenCode v2 agents with wildcard permissions, custom system prompts, and model tier assignments. |
| **`ctx.tool`** | `tool.transform(...)`<br>`hook("execute.before")`<br>`hook("execute.after")` | Registers `es_*` tools. Intercepts and blocks unsafe actions. Intercepts web searches and fetches to cache content-addressed sources. |
| **`ctx.permission`** | `hook("evaluate")` | Evaluates fine-grained access control before tool execution; enforces deny-write on control files (`.factory/**`, `.git/config`). |
| **`ctx.shell`** | `hook("create.before")` | Injects sandboxed environment variables and wraps execution in Linux `bwrap` (Bubblewrap) containers per seat role. |
| **`ctx.session`** | `hook("prompt")`<br>`hook("context")`<br>`hook("compaction")` | Dynamic context injection, prompt auditing, and compaction protection for factory invariants. |
| **`ctx.rpc`** | `rpc.register(EsRpc, ...)` | Exposes strongly-typed JSON-RPC methods and SSE `changed` events for status, liveness, and panel rendering. |
| **`ctx.command`** | `command.transform(...)` | Registers palette commands (`/grill`, `/factory`, `/brainstorm`, `/harvest`, `/design`, `/scout`, `/audit`). |
| **`session.panel`** | SolidJS Panel Slot | Renders custom interactive panels inside the OpenCode TUI: Factory Dashboard, LSP Manager, Hooks Inspector, Brainstorm Board. |
| **`prompt.footer`** | Footer Slot | Renders live seat status, active run stage, and spend indicators right above the prompt. |
| **`context.ui`** | `dialog.alert`, `confirm`, `toast` | Displays modal dialogs and toasts for human inspection of pending approvals and state warnings. |
| **`keymap.layer`** | Keymap Layering | Binds scoped keys (`Esc`, `q`, `f`, `r`) strictly to active TUI panel instances. |
---
### 3.4. Subsystem 4: Distributed Fleet Orchestration Engine (`packages/fleet`)
#### Problem Statement
Executing complex projects sequentially in a single terminal session creates bottlenecks. A project may have parallel phases (frontend, backend, documentation), multiple worktrees, or require coordinated multi-repo work (e.g. coordinating `IUMBTEMS` and `scraper-swarm`).
#### Architecture: The `es-fleet` Daemon
`packages/fleet` introduces a lightweight, robust orchestration daemon:
1. **Job Graph & Scheduler:**
- Manages a directed acyclic graph (DAG) of task units (e.g. `Brainstorm ➔ Research ➔ Spec ➔ [Build Backend || Build Frontend] ➔ Integration QA`).
- Assigns task units to worker instances based on model tiers, tool capabilities, and worktree isolation.
2. **Worker Abstraction:**
- **Headless OpenCode Workers:** Spawns and manages `opencode` instances running in server mode, controlling them via RPC and session hooks.
- **Headless CLI Workers:** Runs targeted `es` commands (`es build`, `es qa`, `es audit`, `es harvest`) in isolated git worktrees.
3. **Multi-Worktree Coordination:**
- Allocates dedicated git worktrees for each concurrent task (`.fleet/worktrees/<task-id>/`).
- Ensures no two active programmers or QA agents touch the same working directory.
- Merges verified phase branches into integration worktrees only after mechanical gate approval.
4. **Unified Fleet Bus:**
- Real-time WebSocket and HTTP API for telemetry, streaming logs, seat liveness fingerprints, and job states to the WebUI and CLI.
---
### 3.5. Subsystem 5: Web-Centric Control Plane (`packages/web`)
#### Architecture & Security Design
The WebUI provides full observability, interactive configuration, and a **browser-native cryptographic approval channel**:
```mermaid
sequenceDiagram
autonumber
actor Human as Human Operator
participant Browser as WebUI Client (packages/web)
participant Crypto as In-Memory WebCrypto / Noble-Ed25519
participant Fleet as Fleet Daemon (packages/fleet)
participant State as Run State & Gates
State->>Fleet: Stage Transition Held (Pending Human Approval)
Fleet->>Browser: Push Pending Approval Event (Hash: abc123def...)
Browser->>Human: Render Visual Diff & Verification Modal
Human->>Browser: Enters Passphrase & Clicks "Approve & Sign"
Browser->>Crypto: Decrypt Sealed Ed25519 Key (scrypt + AES-GCM) in memory
Crypto->>Crypto: Sign Hash-Bound Approval Record
Crypto->>Crypto: Zeroize Decrypted Key Memory
Browser->>Fleet: POST /api/approvals (Signed Payload + Signature)
Fleet->>State: Verify Ed25519 Signature against Human Public Key
State->>State: Transition Factory Stage (Frontier / Spec Approved)
```
#### Key Capabilities:
1. **Interactive Fleet Dashboard:**
- Visual DAG of all active runs, stages, and worktree branches.
- Real-time seat liveness indicators (who is thinking, running tools, or blocked).
- Live token and USD spend tracking against ceilings.
2. **Configuration & Policy Management:**
- Visual editors for `gates.json`, model tier mappings, and domain packs.
- Diff preview against control-file hashes with rebaseline warnings.
3. **Evidence & Claim Explorer:**
- Interactive claim graph with citations, verbatim quotes, and witness states (`VERIFIED`, `INFERRED`, `HYPOTHESIS`).
- Source cache inspector showing sealed document contents.
4. **Client-Side Cryptographic Signing:**
- Sealed Ed25519 key remains encrypted with scrypt + AES-256-GCM.
- Passphrase entry decrypts the key in client-side memory strictly for signing; raw private keys are **never transmitted** over the wire to the daemon or cloud.
---
### 3.6. Subsystem 6: Recursive Self-Improvement Engine
Recursive self-improvement is implemented not as unconstrained self-rewriting, but as a **tight, mechanically gated evolutionary loop**:
```mermaid
flowchart TD
Run["Production & Eval Runs"] --> Telemetry["Operational Telemetry Store"]
subgraph Distillation["1. Telemetry & Distillation Loop"]
Telemetry --> Analyzer["Pattern Harvester"]
Analyzer -->|Extract| QA_Failures["QA Replans & Failures"]
Analyzer -->|Extract| Audit_Catches["Code Audit Findings"]
Analyzer -->|Extract| Gate_Violations["Gate Rejections"]
QA_Failures & Audit_Catches --> PromptTuning["Prompt & Rubric Refinement"]
Gate_Violations --> Heuristics["Gate Budget & AST Tuning"]
end
subgraph SelfDogfood["2. Self-Dogfooding Build Loop"]
Backlog["Backlog Issue / Self-Improvement Goal"] --> FactoryRun["Factory Build in Isolated Worktree"]
PromptTuning & Heuristics --> FactoryRun
FactoryRun --> Gates["Mechanical Gates"]
Gates --> FireSuites["Deterministic Fire Suites (5 Suites)"]
FireSuites --> Graders["Model Graders & Evals"]
Graders --> DraftPR["Draft Pull Request (Human Merges)"]
end
DraftPR -->|Human Review & Merge| Release["Tagged Release Cut"]
Release --> Run
```
#### Key Guarantees:
1. **Deterministic Bounding:** Any self-generated change to prompts, tools, or core code must pass all five merge-blocking fire suites (`grill-fires`, `factory-gate`, `darkharvest-fires`, `audit-fires`, `scout-fires`) and drift checks.
2. **Non-Negotiable Human Cutover:** The factory cannot push directly to `main` or cut releases autonomously; it opens draft PRs for human review and cryptographic approval.
3. **Empirical Knowledge Accumulation:** Successful research methodologies and domain definitions are archived into reusable domain packs (`packages/core/assets/domain/`).
---
## 4. Phased Milestone Roadmap
```mermaid
gantt
title Epistemic Swarm Evolutionary Roadmap
dateFormat YYYY-MM-DD
section Phase 1: Epistemic Layer
Dual-Mode Brainstorm & Darkharvest :milestone, m1, 2026-10-15, 1d
Scraper-Swarm Integration :p1_ss, 2026-10-16, 10d
Adversarial Deep Research Swarm :p1_dr, 2026-10-26, 12d
section Phase 2: Fleet Engine
packages/fleet Orchestrator Daemon :p2_fl, 2026-11-08, 15d
Multi-Worktree & Session Scheduler :p2_ws, 2026-11-23, 12d
Fleet RPC & WebSocket Bus :p2_bus, 2026-12-05, 8d
section Phase 3: WebUI & Control Plane
packages/web Dashboard Core :p3_ui, 2026-12-14, 14d
Client-Side Ed25519 Signing :p3_sig, 2026-12-28, 10d
Config & Evidence Explorer :p3_exp, 2027-01-08, 12d
section Phase 4: Self-Improvement
Telemetry Distillation Pipeline :p4_tel, 2027-01-21, 12d
Autonomous Self-Dogfooding Factory :p4_dog, 2027-02-03, 14d
```
### Milestone 1: Epistemic & Lateral Agent Layer (Target: 3 Weeks)
- [ ] Export `brainstormer` and `harvester` as typed subagent tools (`es_brainstorm_fanout`, `es_harvest_target`).
- [ ] Implement Scraper-Swarm gateway MCP client in `packages/core/src/research/scraperswarm.ts` with rate-limiting and fallback to SearXNG/host websearch.
- [ ] Build the standalone `deep-researcher` adversarial swarm (Thesis + Antithesis + Synthesizer).
- [ ] Add CLI verbs: `es research deep <query> --output <path>` and `es harvest scan <repo>`.
- [ ] Add merge-blocking fire suite `research-fires.test.ts` proving claim verification, quote matching, and Scraper-Swarm fallback.
### Milestone 2: Fleet Orchestration Daemon (`packages/fleet`) (Target: 4 Weeks)
- [ ] Create `packages/fleet` within the Bun workspace.
- [ ] Implement the DAG Task Scheduler and Worktree Coordinator (`.fleet/worktrees/`).
- [ ] Implement the headless OpenCode session runner using `ctx.session.create` and process management.
- [ ] Expose unified JSON-RPC and WebSocket event stream (`/fleet/ws`, `/fleet/api`).
- [ ] CLI command `es fleet start --port 8080` and `es fleet status`.
- [ ] Integration tests verifying concurrent worktree isolation and tiebreak resolution across sessions.
### Milestone 3: Web-Centric Control Plane (`packages/web`) (Target: 3 Weeks)
- [ ] Create `packages/web` (Vite + React / Tailwind or SolidJS) connecting to `es-fleet`.
- [ ] Build real-time fleet DAG and seat liveness dashboard.
- [ ] Implement in-memory WebCrypto / Noble-Ed25519 client-side passphrase unlock and hash-bound signing.
- [ ] Build interactive Evidence Graph and Claim Inspector with verbatim source quote viewing.
- [ ] Visual editors for `config.json`, `gates.json`, and domain packs with hash diff preview.
### Milestone 4: Recursive Self-Improvement & Fleet Telemetry (Target: 3 Weeks)
- [ ] Build telemetry event collector in `packages/fleet` logging QA replan causes, code audit catches, and gate rejections.
- [ ] Build distillation pipeline extracting high-frequency failure patterns into domain packs and prompt adjustments.
- [ ] Implement self-dogfooding factory pipeline: tasked with internal issues, executing in worktrees, and running the five fire suites.
- [ ] End-to-end evaluation suite measuring factory success rate and replan count improvements over iterations.
---
## 5. Risk Assessment & Mitigations
| Risk | Impact | Mitigation |
| :--- | :--- | :--- |
| **Browser Key Exposure** | Critical | Private keys are decrypted in client-side RAM only during the signing action and immediately zeroized; keys are never stored unencrypted in `localStorage` or transmitted across the network. |
| **Scraper-Swarm Downtime** | Moderate | The research client implements automatic fallback to configured host web search / SearXNG with warning tags in the dossier metadata. |
| **Multi-Worktree Disk Growth** | Moderate | Fleet orchestrator prunes completed and released worktrees automatically; worktrees share git object storage via git alternates. |
| **Recursive Drift / Hallucination** | High | Invariant #7 and deterministic fire suites remain merge-blocking; self-improvement proposals require explicit human review and cryptographic merge approval. |
| **OpenCode v2 API Churn** | Low | Integration is maintained against upstream v2 HEAD via `scripts/v2-head.sh` nightly compatibility CI. |
---
## 6. Verification & Proof Artifacts
Every milestone will produce enforceable proofs in accordance with `AGENTS.md`:
1. **Capability Matrix Entries:** Added to `packages/core/src/capabilities.ts` and validated via `bun run docs:check`.
2. **Fire Suites:** Deterministic, in-process host test suites in `packages/opencode/test/` proving zero-drift execution.
3. **Spike Records:** Saved in `spikes/M<N>-<name>/RESULTS.md` with benchmark timings and token expenditures.
+20 -4
View File
@@ -1,11 +1,27 @@
{
"id": "auditor-thesis",
"description": "The thesis auditor maps a login helper, names its exploitable defect with the CWE and the file:line range.",
"description": "The thesis auditor maps a login helper, names its exploitable defect with the CWE and the file:line range, and records a fail verdict.",
"agent": "es-auditor-thesis",
"prompt": "Map the invariants of src/login.ts. Report any exploitable defect with its CWE id and the file:line range it sits on. Inspect the file with the read tool, never the shell; do not run gates. This run is headless: never call the question tool. Make independent tool calls in the same block.",
"prompt": "Code audit audit-01 is open on path src. Map the invariants of src/login.ts. Report any exploitable defect with its CWE id and the file:line range it sits on, then record one fail verdict with es_audit_verdict: each finding with its CWE, exact lines and a verbatim excerpt. Inspect the file with the read tool, never the shell; do not run gates. This run is headless: never call the question tool. Make independent tool calls in the same block.",
"maxSteps": 14,
"files": {
"src/login.ts": "export async function login(db: any, username: string, password: string) {\n const query = \"SELECT * FROM users WHERE name = '\" + username + \"' AND pass = '\" + password + \"'\"\n return db.query(query)\n}\n"
"src/login.ts": "export async function login(db: any, username: string, password: string) {\n const query = \"SELECT * FROM users WHERE name = '\" + username + \"' AND pass = '\" + password + \"'\"\n return db.query(query)\n}\n",
".factory/runtime/state.json": "{\n \"version\": 2,\n \"runId\": \"run-20261007-000000-thesis\",\n \"stage\": \"GRILL\",\n \"createdAt\": \"2026-10-07T00:00:00.000Z\",\n \"updatedAt\": \"2026-10-07T00:00:00.000Z\",\n \"spendCeilingUSD\": 2,\n \"spend\": {\n \"usd\": 0,\n \"estimated\": false,\n \"events\": 0\n },\n \"phases\": [],\n \"audits\": [\n {\n \"id\": \"audit-01\",\n \"target\": {\n \"kind\": \"path\",\n \"path\": \"src\"\n },\n \"round\": 1,\n \"status\": \"open\",\n \"openedAt\": \"2026-10-07T00:00:00.000Z\",\n \"openedBy\": \"human:eval\"\n }\n ]\n}\n"
},
"checks": { "contains": ["CWE-89", "src/login.ts"] }
"checks": {
"contains": ["CWE-89", "src/login.ts"],
"toolsUsed": ["es_audit_verdict"]
},
"fire": {
"kind": "audit",
"audit": "audit-01",
"plants": [
{
"id": "sql-injection",
"file": "src/login.ts",
"lines": [2, 2],
"cwe": ["CWE-89"]
}
]
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "deep-research",
"description": "The deep-researcher runs a research-only run adversarially (thesis, antithesis, synthesis) to a grounded REPORT.md and completes at DONE. Reported, never blocking.",
"agent": "deep-researcher",
"prompt": "Deep research: do mechanical gates keep autonomous agents honest? This is a headless run: no human will answer questions, so write the plan note, launch es-research-alpha and es-research-beta in parallel (both subagent calls in one message, in the foreground), then es-research-synthesizer with the disputes, then es_research_audit and es_research_complete. Tag every claim; quotes must be verbatim from cached sources.",
"maxSteps": 22,
"checks": {
"toolsUsed": ["es_research_complete"],
"contains": ["DONE"]
}
}
+2 -2
View File
@@ -7,8 +7,8 @@
"packages/*"
],
"scripts": {
"test": "bun test ./packages",
"typecheck": "for p in core testkit opencode cli; do tsc -p packages/$p/tsconfig.json --noEmit --composite false || exit 1; done",
"test": "bun --conditions=browser test $(bun scripts/packages.ts --browser | sed 's|^|packages/|') && bun test $(bun scripts/packages.ts --no-browser | sed 's|^|packages/|')",
"typecheck": "for p in $(bun scripts/packages.ts --tsconfig); do tsc -p packages/$p/tsconfig.json --noEmit --composite false || exit 1; done",
"lint": "biome check .",
"deps:check": "bun scripts/deps.ts",
"evals": "bun scripts/evals.ts",
+6 -2
View File
@@ -1,6 +1,9 @@
{
"name": "@heretek-ai/es-cli",
"version": "1.1.4",
"version": "1.6.0",
"esRelease": {
"order": 2
},
"description": "Epistemic Swarm CLI: factory control, human-only checkpoints, gates, headless runs and a coarse MCP server.",
"type": "module",
"license": "Apache-2.0",
@@ -31,7 +34,8 @@
"test": "bun test"
},
"dependencies": {
"@heretek-ai/es-core": "workspace:*"
"@heretek-ai/es-core": "workspace:*",
"zod": "4.6.5"
},
"engines": {
"node": ">=22"
+3 -30
View File
@@ -1,33 +1,6 @@
// Minimal argv parser: positionals, --flag, --flag=value, --flag value.
export interface Args {
readonly positionals: string[]
readonly flags: Record<string, string | boolean>
}
export function parseArgs(argv: readonly string[], booleans: readonly string[] = []): Args {
const positionals: string[] = []
const flags: Record<string, string | boolean> = {}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!
if (arg === "--") {
positionals.push(...argv.slice(i + 1))
break
}
if (arg.startsWith("--")) {
const [name, inline] = arg.slice(2).split(/=(.*)/s)
if (inline !== undefined) flags[name!] = inline
else if (booleans.includes(name!) || argv[i + 1] === undefined || argv[i + 1]!.startsWith("--"))
flags[name!] = true
else flags[name!] = argv[++i]!
} else positionals.push(arg)
}
return { positionals, flags }
}
export const flag = (args: Args, name: string): string | undefined => {
const value = args.flags[name]
return typeof value === "string" ? value : undefined
}
// CLI-only argument helpers. The grammar itself (Args, parseArgs, flag,
// BOOLEAN_FLAGS) lives in core (util/args.ts, #97) so the CLI parser and the
// human-only policy cannot drift apart; import it from @heretek-ai/es-core.
/** "7d", "12h", "90m" or an ISO date → Date. */
export function parseExpiry(value: string, now = new Date()): Date {
+1 -1
View File
@@ -1,8 +1,8 @@
// `es gates`: run the gates from a terminal or git hook, and opt-in git hooks.
import { chmod, mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import type { Args } from "@heretek-ai/es-core"
import { formatReport, git, runGates } from "@heretek-ai/es-core"
import type { Args } from "./args.ts"
import type { HumanContext } from "./human.ts"
import { confirmHuman } from "./tty.ts"
+265 -7
View File
@@ -9,15 +9,22 @@ import path from "node:path"
import { createInterface } from "node:readline"
import {
checkStopFile,
currentBranch,
stateDir as defaultStateDir,
exists,
Factory,
type FactoryState,
gateRunner,
git,
headline,
indexRun,
type Liveness,
pendingApprovals,
progressPrint,
readLiveness,
seatOf,
} from "@heretek-ai/es-core"
import { z } from "zod"
/** A seat as the progress and stall events report it. */
export interface SeatSnapshot {
@@ -32,13 +39,86 @@ export type HeadlessEvent =
| { type: "turn"; n: number; stage: string; activePhase?: string }
| { type: "driver"; event: unknown }
| { type: "progress"; headline: string; lastActivityAgoSec?: number; seats: SeatSnapshot[] }
| {
type: "turn-metrics"
turn: number
/** Sealed run-state spend delta across the turn (USD, 0 when the turn spent nothing). */
costUSD: number
/** Opportunistic per-turn tokens from the child's events, when the driver reports them. */
tokensIn?: number
tokensOut?: number
tools: Array<{ name: string; ok: boolean; ms?: number; seat?: string }>
}
| { type: "waiting"; reason: string; stages?: string[] }
| { type: "halted"; reason: string }
| { type: "stalled"; turns: number; headline: string; lastActivityAgoSec?: number; seats: SeatSnapshot[] }
| { type: "turn-cap"; turns: number }
| { type: "cancelled"; reason: string }
| { type: "done"; prUrl?: string; audit?: string; status?: string; report?: string }
| { type: "error"; message: string }
/** Process exit for one headless event kind: cancellation is 130, failures 1, the rest 0. */
export function headlessExitCode(type: HeadlessEvent["type"]): number {
if (type === "cancelled") return 130
if (type === "error" || type === "halted" || type === "stalled" || type === "turn-cap") return 1
return 0
}
/** Version of the `--events jsonl` envelope (bump on any incompatible change). */
export const JSONL_VERSION = 1 as const
/** One JSONL line: the envelope plus the full event, so the fleet can parse without joining streams. */
export interface JsonlEnvelope {
readonly v: typeof JSONL_VERSION
readonly at: string
readonly runId: string
readonly kind: HeadlessEvent["type"]
readonly event: HeadlessEvent
}
/** One tool call in a `turn-metrics` payload: duration and seat when the driver reports them. */
export const TurnMetricsToolSchema = z.object({
name: z.string().min(1),
ok: z.boolean(),
ms: z.number().nonnegative().optional(),
seat: z.string().min(1).optional(),
})
/** A `turn-metrics` payload: validated on its own, not just as an envelope. */
export const TurnMetricsEventSchema = z.object({
type: z.literal("turn-metrics"),
turn: z.number().int().positive(),
costUSD: z.number(),
tokensIn: z.number().optional(),
tokensOut: z.number().optional(),
tools: z.array(TurnMetricsToolSchema),
})
export const HeadlessJsonlSchema = z
.object({
v: z.literal(1),
at: z.string().datetime(),
runId: z.string().min(1),
kind: z.string().min(1),
event: z.record(z.string(), z.unknown()),
})
.superRefine((envelope, ctx) => {
if (envelope.kind !== "turn-metrics") return
const payload = TurnMetricsEventSchema.safeParse(envelope.event)
if (!payload.success)
ctx.addIssue({
code: "custom",
message: `invalid turn-metrics payload: ${payload.error.issues.map((issue) => issue.message).join("; ")}`,
path: ["event"],
})
})
export type HeadlessJsonl = z.infer<typeof HeadlessJsonlSchema>
/** Wrap one headless event in its versioned JSONL envelope. */
export function toJsonl(runId: string, event: HeadlessEvent): JsonlEnvelope {
return { v: JSONL_VERSION, at: new Date().toISOString(), runId, kind: event.type, event }
}
/** Where a human watches a headless run. */
export const MONITOR_HINT = "watch it with `es status`, `es watch`, or /es-factory in the OpenCode TUI"
@@ -48,6 +128,8 @@ export interface DriverTurn {
readonly prompt: string
readonly session?: string
readonly signal?: AbortSignal
/** Seat model override, "provider/model" (human-set flags only). */
readonly model?: string
}
export interface HarnessDriver {
@@ -76,7 +158,7 @@ function resolveOnPath(binary: string): string | undefined {
export const opencodeDriver: HarnessDriver = {
id: "opencode",
available: () => Promise.resolve(resolveOnPath("opencode") !== undefined),
async *turn({ root, agent, prompt, session, signal }) {
async *turn({ root, agent, prompt, session, signal, model }) {
const args = [
"run",
"--standalone",
@@ -84,6 +166,7 @@ export const opencodeDriver: HarnessDriver = {
"json",
"--agent",
agent,
...(model ? ["--model", model] : []),
...(session ? ["--session", session] : []),
prompt,
]
@@ -126,8 +209,12 @@ export interface HeadlessOptions {
readonly stallTurns?: number
readonly stateDir?: string
readonly signal?: AbortSignal
/** Seat model override, "provider/model" (human-set flags only). */
readonly model?: string
/** How often a `progress` event is emitted while a turn runs (default 30 s). */
readonly progressMs?: number
/** Abort a turn that runs longer than this (ms); reported as a `turn-timeout` error. */
readonly turnTimeoutMs?: number
}
const secondsSince = (liveness: Liveness, at: string | undefined) =>
@@ -156,6 +243,16 @@ async function progressReport(root: string, factory: Factory) {
}
const TICK = Symbol("tick")
const ABORTED = Symbol("aborted")
/** A turn that ran past `--turn-timeout`: aborted and reported, never retried. */
class TurnTimeout extends Error {}
/** The error for a turn aborted by `--turn-timeout` (seconds). */
const turnTimeout = (n: number, timeoutMs: number) =>
new TurnTimeout(`turn ${n} timed out after ${timeoutMs / 1000}s (turn-timeout); the run state is untouched`)
const isTurnTimeout = (error: unknown): error is TurnTimeout => error instanceof TurnTimeout
/** A cancellable timer that resolves to TICK. */
function tick(ms: number) {
@@ -176,10 +273,98 @@ export interface HeadlessJob {
readonly progress: (state: FactoryState) => Promise<string>
}
/**
* Refuse an unsafe `--cwd` root, or return undefined when it is fine.
* Refuses a path inside another active run's `.factory/worktrees/` (a seat
* worktree is not a run root) and a repo checkout with a detached HEAD
* (a run must be attributable to a branch). A non-repo directory is fine.
*/
export async function checkCwd(root: string): Promise<string | undefined> {
const resolved = path.resolve(root)
// Inside <project>/.factory/worktrees/ while that project holds a run state?
let dir = path.dirname(resolved)
while (dir !== path.dirname(dir)) {
const seatRoot = path.join(dir, ".factory", "worktrees")
if (`${resolved}${path.sep}`.startsWith(`${seatRoot}${path.sep}`)) {
if (await exists(path.join(dir, ".factory", "runtime", "state.json")))
return `refusing --cwd inside another run's seat worktrees (${seatRoot}): point it at a run root or a fresh worktree instead`
break
}
dir = path.dirname(dir)
}
const gitDir = await git(resolved, ["rev-parse", "--absolute-git-dir"], { allowFail: true })
if (gitDir.code !== 0) return undefined
if ((await currentBranch(resolved)) === undefined)
return `refusing --cwd with a detached HEAD (${resolved}): check out a branch first, so the run is attributable`
return undefined
}
/** Tool activity in one harness event, using the same shape as the log presenter. */
function toolActivity(raw: unknown): { name: string; ok: boolean; ms?: number; seat?: string } | undefined {
const event = raw as {
type?: string
agent?: unknown
part?: { tool?: string; state?: { status?: string; time?: { start?: unknown; end?: unknown } } }
}
if (event?.type === "tool_use" && typeof event.part?.tool === "string") {
const activity: { name: string; ok: boolean; ms?: number; seat?: string } = {
name: event.part.tool,
ok: event.part.state?.status === "completed",
}
const { start, end } = event.part.state?.time ?? {}
if (
typeof start === "number" &&
typeof end === "number" &&
Number.isFinite(start) &&
Number.isFinite(end) &&
end >= start
)
activity.ms = end - start
if (typeof event.agent === "string") {
const seat = seatOf(event.agent)
if (seat !== undefined) activity.seat = seat
}
return activity
}
return undefined
}
const TOKEN_KEYS: Readonly<Record<string, "in" | "out">> = {
tokensIn: "in",
inputTokens: "in",
promptTokens: "in",
tokensOut: "out",
outputTokens: "out",
completionTokens: "out",
}
/** Opportunistic per-turn tokens from a harness event, when the driver reports them. */
function eventTokens(raw: unknown): { tokensIn?: number; tokensOut?: number } {
let tokensIn: number | undefined
let tokensOut: number | undefined
const walk = (value: unknown, depth: number): void => {
if (depth > 6 || value === null || typeof value !== "object" || (tokensIn !== undefined && tokensOut !== undefined))
return
if (Array.isArray(value)) {
for (const item of value) walk(item, depth + 1)
return
}
for (const [key, item] of Object.entries(value)) {
if (typeof item === "number" && Number.isFinite(item)) {
if (TOKEN_KEYS[key] === "in") tokensIn ??= item
else if (TOKEN_KEYS[key] === "out") tokensOut ??= item
} else walk(item, depth + 1)
}
}
walk(raw, 0)
return { ...(tokensIn !== undefined ? { tokensIn } : {}), ...(tokensOut !== undefined ? { tokensOut } : {}) }
}
/**
* Drive one seat through a harness CLI until the job finishes, the run halts
* (STOP, spend ceiling, runtime cap, drift), progress stalls or the turn cap is
* reached. It never answers human checkpoints.
* (STOP, spend ceiling, runtime cap, drift), progress stalls, the turn cap is
* reached, the turn times out, or a signal cancels the run. It never answers
* human checkpoints. Cancellation leaves the run state untouched (no halt),
* so re-running resumes where it stopped.
*/
export async function* driveHeadless(options: HeadlessOptions, job: HeadlessJob): AsyncGenerator<HeadlessEvent> {
const factory = new Factory(options.root, {
@@ -195,7 +380,26 @@ export async function* driveHeadless(options: HeadlessOptions, job: HeadlessJob)
yield { type: "error", message: `The ${options.driver.id} CLI is not installed or not on PATH.` }
return
}
// The --cwd path is this run's root: keep the per-user run index fresh even
// when no turn changes state (indexRun is best-effort and never throws).
await indexRun(options.stateDir ?? defaultStateDir(), {
runId: state.runId,
root: options.root,
stage: state.stage,
createdAt: state.createdAt,
updatedAt: state.updatedAt,
})
yield { type: "start", runId: state.runId, stage: state.stage, driver: options.driver.id, monitor: MONITOR_HINT }
// A signal that fires while a turn hangs must still end the race, even with
// a driver that never listens to it.
const aborted =
options.signal === undefined
? undefined
: options.signal.aborted
? Promise.resolve(ABORTED)
: new Promise<typeof ABORTED>((resolve) =>
options.signal!.addEventListener("abort", () => resolve(ABORTED), { once: true }),
)
let session: string | undefined
let unchanged = 0
const maxTurns = options.maxTurns ?? 200
@@ -214,27 +418,52 @@ export async function* driveHeadless(options: HeadlessOptions, job: HeadlessJob)
}
yield { type: "turn", n, stage: state.stage, ...(state.activePhase ? { activePhase: state.activePhase } : {}) }
const before = await job.progress(state)
const spendBefore = state.spend.usd
const turn = options.driver.turn({
root: options.root,
agent: job.agent,
prompt: await job.prompt(state, factory),
...(session ? { session } : {}),
...(options.signal ? { signal: options.signal } : {}),
...(options.model ? { model: options.model } : {}),
})
const tools: Array<{ name: string; ok: boolean; ms?: number; seat?: string }> = []
let tokensIn: number | undefined
let tokensOut: number | undefined
const deadline = options.turnTimeoutMs === undefined ? undefined : Date.now() + options.turnTimeoutMs
// A turn can run for many minutes (foreground seats): report liveness
// while it does, so "quiet" is never mistaken for "stuck".
try {
let pending = turn.next()
for (;;) {
const timer = tick(options.progressMs ?? 30_000)
let next: IteratorResult<unknown, string | undefined> | typeof TICK
const remaining = deadline === undefined ? undefined : Math.max(0, deadline - Date.now())
if (remaining === 0) throw turnTimeout(n, options.turnTimeoutMs!)
const timer = tick(Math.min(options.progressMs ?? 30_000, ...(remaining === undefined ? [] : [remaining])))
let next: IteratorResult<unknown, string | undefined> | typeof TICK | typeof ABORTED
try {
next = await Promise.race([pending, timer.promise])
next = await Promise.race([pending, timer.promise, ...(aborted === undefined ? [] : [aborted])])
} finally {
// Also when the turn rejects: a live timer would keep the CLI alive.
timer.cancel()
}
if (next === ABORTED) {
// Best-effort close: a stuck driver may never settle its pending
// await, so never block cancellation on it.
turn.return?.(undefined)?.then(
() => undefined,
() => undefined,
)
yield { type: "cancelled", reason: "cancelled by signal; the run state is untouched, re-run to resume" }
return
}
if (next === TICK) {
if (deadline !== undefined && Date.now() >= deadline) {
turn.return?.(undefined)?.then(
() => undefined,
() => undefined,
)
throw turnTimeout(n, options.turnTimeoutMs!)
}
yield { type: "progress", ...(await progressReport(options.root, factory)) }
continue
}
@@ -242,14 +471,40 @@ export async function* driveHeadless(options: HeadlessOptions, job: HeadlessJob)
session = next.value ?? session
break
}
const activity = toolActivity(next.value)
if (activity) tools.push(activity)
const tokens = eventTokens(next.value)
tokensIn ??= tokens.tokensIn
tokensOut ??= tokens.tokensOut
yield { type: "driver", event: next.value }
pending = turn.next()
}
} catch (error) {
if (isTurnTimeout(error)) {
yield { type: "error", message: (error as Error).message }
return
}
if (aborted !== undefined && options.signal?.aborted) {
turn.return?.(undefined)?.then(
() => undefined,
() => undefined,
)
yield { type: "cancelled", reason: "cancelled by signal; the run state is untouched, re-run to resume" }
return
}
yield { type: "error", message: error instanceof Error ? error.message : String(error) }
return
}
const after = await factory.read()
const costUSD = Math.max(0, Math.round(((after?.spend.usd ?? spendBefore) - spendBefore) * 100) / 100)
yield {
type: "turn-metrics",
turn: n,
costUSD,
...(tokensIn !== undefined ? { tokensIn } : {}),
...(tokensOut !== undefined ? { tokensOut } : {}),
tools,
}
unchanged = after && (await job.progress(after)) === before ? unchanged + 1 : 0
if (unchanged >= (options.stallTurns ?? 3)) {
yield { type: "stalled", turns: unchanged, ...(await progressReport(options.root, factory)) }
@@ -348,9 +603,12 @@ function summarizeDriverEvent(raw: unknown): Record<string, unknown> | undefined
/**
* What to print for one headless event at a log level. `quiet`: lifecycle and
* progress only. `info` (default): plus one compact line per tool call and
* the assistant's text. `debug`: the raw harness events, long strings clipped.
* the assistant's text. `debug`: the raw harness events, long strings
* clipped, and the per-turn metrics. Per-turn metrics otherwise go only to
* the `--events jsonl` stream, so the default output is unchanged by them.
*/
export function presentEvent(event: HeadlessEvent, level: LogLevel): unknown {
if (event.type === "turn-metrics") return level === "debug" ? event : undefined
if (event.type !== "driver") return event
if (level === "quiet") return undefined
if (level === "debug") return { type: "driver", event: clipStrings(event.event, DEBUG_STRING_MAX) }
+114 -35
View File
@@ -2,9 +2,12 @@
// what will be signed, then requires the human passphrase at a TTY.
import { userInfo } from "node:os"
import {
ApprovalError,
type ApprovalStage,
type Args,
applyConfigSet,
approvalSubject,
approveStage,
ClaimStore,
ConfigError,
type ConfigSetPlan,
@@ -12,27 +15,28 @@ import {
engineKeyPath,
engineSignedFiles,
Factory,
factoryLayout,
flag,
gateRunner,
HookEngine,
hashJson,
initFromPreset,
isTrusted,
loadGatesConfig,
planConfigSet,
readJson,
rebaseline,
recordApproval,
recordRetraction,
recordWaiver,
researchSourcesDir,
SourceCache,
researchCache,
run,
SELF_DOGFOOD_BASE_BRANCH,
SELF_DOGFOOD_SPEND_CEILING_USD,
signEngineFile,
stateDir,
trustProject,
verifyControl,
verifyEngineFile,
writeJson,
} from "@heretek-ai/es-core"
import { type Args, flag, parseExpiry } from "./args.ts"
import { parseExpiry } from "./args.ts"
import { type ConfirmIO, confirmHuman } from "./tty.ts"
export interface HumanContext {
@@ -49,6 +53,17 @@ const factoryFor = (context: HumanContext) =>
...(context.stateDir ? { stateDir: context.stateDir } : {}),
})
/** ADR 0002 I3: a preview older than this is refused instead of signed. */
export const APPROVAL_PREVIEW_TTL_MS = 120_000
/** Attempt limiting for one human confirm (ADR 0002 I4, per state dir, shared with the TUI). */
const confirmOptions = (context: HumanContext, stage: string) => ({
...(context.stateDir ? { stateDir: context.stateDir } : {}),
root: context.root,
stage,
channel: "cli",
})
export async function approve(context: HumanContext, args: Args): Promise<number> {
const stage = args.positionals[0] as ApprovalStage | undefined
if (stage !== "frontier" && stage !== "spec") {
@@ -56,31 +71,43 @@ export async function approve(context: HumanContext, args: Args): Promise<number
return 2
}
const subject = await approvalSubject(context.root, stage)
// ADR 0002 I3: bind the sign to what was previewed. The hash and the
// preview time are captured before the passphrase prompt; approveStage
// re-derives the subject and refuses a mismatch, and a stale preview is
// refused before anything is signed.
const expectedSubjectHash = hashJson(subject.subject)
const previewedAt = Date.now()
const lines = [...subject.summary, "", ...subject.subject.map((item) => `${item.sha256.slice(0, 12)} ${item.path}`)]
const signer = await confirmHuman(context.io, `Approve ${stage} as ${user()}`, lines, context.stateDir)
const signer = await confirmHuman(context.io, `Approve ${stage} as ${user()}`, lines, confirmOptions(context, stage))
if (!signer) {
context.print("Cancelled; nothing was approved.")
return 1
}
const record = await recordApproval(context.root, {
stage,
channel: "cli",
signer,
...(context.stateDir ? { stateDir: context.stateDir } : {}),
})
const pending =
(await readJson<Array<{ stage: string }>>(factoryLayout(context.root).pending).catch(() => undefined)) ?? []
await writeJson(
factoryLayout(context.root).pending,
pending.filter((item) => item.stage !== stage),
)
if (stage === "frontier") {
const factory = factoryFor(context)
if (!(await factory.read())) await factory.begin(`human:${record.approvedBy}`)
if ((await factory.read())?.stage === "GRILL") await factory.beginResearch(`human:${record.approvedBy}`)
try {
if (Date.now() - previewedAt > APPROVAL_PREVIEW_TTL_MS) {
context.print(`The ${stage} preview expired (over 120 s old); run \`es approve ${stage}\` again.`)
return 1
}
// One code path for every surface (#117): the service records, clears
// pending and (for frontier) begins research under its own locks.
const { record, alreadyApproved } = await approveStage(context.root, {
stage,
channel: "cli",
signer,
expectedSubjectHash,
...(context.stateDir ? { stateDir: context.stateDir } : {}),
})
context.print(
`${alreadyApproved ? "Already approved" : "Approved"} ${stage} (${record.subject.length} artifact(s)) as ${record.approvedBy}.`,
)
return 0
} catch (error) {
if (!(error instanceof ApprovalError)) throw error
context.print(`What was previewed changed; run \`es approve ${stage}\` again.`)
return 1
} finally {
signer.destroy()
}
context.print(`Approved ${stage} (${record.subject.length} artifact(s)) as ${record.approvedBy}.`)
return 0
}
export async function trust(context: HumanContext, args: Args): Promise<number> {
@@ -113,7 +140,7 @@ export async function trust(context: HumanContext, args: Args): Promise<number>
context.io,
"Trust these gate commands and project hooks (they run project code)",
lines,
context.stateDir,
confirmOptions(context, "trust"),
)
if (!signer) {
context.print("Cancelled.")
@@ -141,7 +168,7 @@ export async function rebaselineControl(context: HumanContext): Promise<number>
context.io,
`Accept these control-file changes as ${user()}`,
[...check.violations],
context.stateDir,
confirmOptions(context, "rebaseline"),
))
) {
context.print("Cancelled; the baseline is unchanged.")
@@ -202,7 +229,7 @@ export async function reseal(context: HumanContext, sign: boolean): Promise<numb
context.io,
`Re-sign ${problems.size} engine file(s) as ${user()} (review them first: a mismatch can mean tampering)`,
lines,
context.stateDir,
confirmOptions(context, "reseal"),
))
) {
context.print("Cancelled; nothing was signed.")
@@ -232,7 +259,7 @@ export async function waive(context: HumanContext, args: Args): Promise<number>
.replace(/^-|-$/g, "")
.toLowerCase()}-${Date.now().toString(36)}`.slice(0, 64)
const lines = [`Rule: ${rule}`, `Files: ${files}`, `Reason: ${reason}`, `Expires: ${expiresAt.toISOString()}`]
const signer = await confirmHuman(context.io, `Grant waiver ${id}`, lines, context.stateDir)
const signer = await confirmHuman(context.io, `Grant waiver ${id}`, lines, confirmOptions(context, "waive"))
if (!signer) {
context.print("Cancelled.")
return 1
@@ -272,7 +299,7 @@ export async function resume(context: HumanContext, args: Args): Promise<number>
...(raise ? [`New spend ceiling: $${raise}`] : []),
...(args.flags["extend-runtime"] ? ["Runtime cap restarts now."] : []),
]
if (!(await confirmHuman(context.io, "Resume the factory", lines, context.stateDir))) {
if (!(await confirmHuman(context.io, "Resume the factory", lines, confirmOptions(context, "resume")))) {
context.print("Cancelled.")
return 1
}
@@ -292,12 +319,57 @@ export async function recordPr(context: HumanContext, args: Args): Promise<numbe
context.print("Usage: es factory pr <url>")
return 2
}
if (!(await confirmHuman(context.io, "Record the release PR", [url], context.stateDir))) return 1
if (!(await confirmHuman(context.io, "Record the release PR", [url], confirmOptions(context, "pr")))) return 1
const state = await factoryFor(context).recordPr(user(), url)
context.print(`Recorded ${url}; factory is ${state.stage}.`)
return 0
}
/**
* Seed a self-dogfood run from a preset and a GitHub issue (human-only: it
* writes factory control files). Pulls the issue body as the idea, writes
* `.factory/` from the preset, and reminds the human about trust, the
* spend ceiling and the frontier/spec approvals.
*/
export async function factoryInit(context: HumanContext, args: Args): Promise<number> {
const preset = flag(args, "preset")
const issueRaw = flag(args, "issue")
const issue = issueRaw !== undefined ? Number(issueRaw) : Number.NaN
if (preset !== "self-dogfood" || !Number.isInteger(issue) || issue <= 0) {
context.print("Usage: es factory init --preset self-dogfood --issue <n>")
return 2
}
const fetched = await run(["gh", "issue", "view", String(issue), "--json", "title,body"], {
cwd: context.root,
timeoutMs: 30_000,
}).catch(() => undefined)
if (fetched?.code !== 0) {
context.print(`Cannot read issue #${issue} (gh issue view failed; is gh authenticated?).`)
return 1
}
const { title, body } = JSON.parse(fetched.stdout) as { title: string; body: string }
const lines = [
`Preset: self-dogfood (release base ${SELF_DOGFOOD_BASE_BRANCH}, gates: bun run check + docs:check)`,
`Issue #${issue}: ${title}`,
"Writes: .factory/config.json, .factory/gates.json, .factory/roadmap.json, .factory/notes/idea-<n>.md",
]
if (
!(await confirmHuman(context.io, `Start a self-dogfood run from #${issue}`, lines, confirmOptions(context, "init")))
) {
context.print("Cancelled; nothing was written.")
return 1
}
const { written } = await initFromPreset(context.root, preset, { issue, title, body }, { actor: `human:${user()}` })
context.print(
[
`Seeded a self-dogfood run from #${issue} (${written.length} file(s)):`,
...written.map((file) => ` ${file}`),
`Next: es trust (gate commands), then /grill with a $${SELF_DOGFOOD_SPEND_CEILING_USD} spend ceiling — keep baseBranch "${SELF_DOGFOOD_BASE_BRANCH}" when the grill rewrites the roadmap — then approve the frontier and spec.`,
].join("\n"),
)
return 0
}
/** Record that a cached source was retracted or revised; every claim citing it degrades. */
export async function retract(context: HumanContext, args: Args): Promise<number> {
const source = args.positionals[0]
@@ -317,7 +389,7 @@ export async function retract(context: HumanContext, args: Args): Promise<number
}
const store = await ClaimStore.load(context.root)
const affected = store.citing(source)
const cached = await new SourceCache(researchSourcesDir(context.root)).get(source)
const cached = await researchCache(context.root, context.stateDir ?? stateDir()).get(source)
const to = event === "RETRACTED" ? "STALE" : "SUSPECT"
const lines = [
`Source: ${source}`,
@@ -327,7 +399,14 @@ export async function retract(context: HumanContext, args: Args): Promise<number
`Claims citing it: ${affected.length} (they become ${to})`,
...affected.slice(0, 10).map((claim) => ` - ${claim.statement.slice(0, 90)}`),
]
if (!(await confirmHuman(context.io, `Record source ${event.toLowerCase()} as ${user()}`, lines, context.stateDir))) {
if (
!(await confirmHuman(
context.io,
`Record source ${event.toLowerCase()} as ${user()}`,
lines,
confirmOptions(context, "retract"),
))
) {
context.print("Cancelled; nothing was recorded.")
return 1
}
@@ -375,7 +454,7 @@ export async function configSet(context: HumanContext, args: Args): Promise<numb
: []),
...(layer === "project" ? ["The control baseline is re-recorded for .factory/config.json."] : []),
]
if (!(await confirmHuman(context.io, `Set ${key} as ${user()}`, lines, context.stateDir))) {
if (!(await confirmHuman(context.io, `Set ${key} as ${user()}`, lines, confirmOptions(context, "config-set")))) {
context.print("Cancelled; the config is unchanged.")
return 1
}
+254
View File
@@ -0,0 +1,254 @@
// `es improve` (#135 harvest, #136 distill): read-only telemetry harvest
// over run and eval dirs, and deterministic distillation into reviewable
// proposals. Both verbs are agent-safe by construction — they only read the
// inputs and write the one `--out` location — so they stay out of HUMAN_VERBS
// in core. Only `--open-pr` publishes, and it is human-run: without a
// terminal it refuses instead of opening anything.
import { readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import {
type Args,
compareStrings,
type DistillThresholds,
disposedWithReasons,
distillProposals,
factoryLayout,
flag,
git,
harvestTelemetry,
type Proposal,
renderProposalFiles,
run,
type Telemetry,
TelemetrySchema,
undisposed,
type WrittenProposal,
writeDropped,
writeProposals,
} from "@heretek-ai/es-core"
import type { ConfirmIO } from "./tty.ts"
import { NotInteractive } from "./tty.ts"
const splitList = (value: string | undefined): string[] =>
value === undefined
? []
: value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
export async function improveHarvest(args: Args, io: { print: (text: string) => void; cwd: string }): Promise<number> {
const runs = splitList(flag(args, "runs")).map((dir) => path.resolve(io.cwd, dir))
const evals = splitList(flag(args, "evals")).map((dir) => path.resolve(io.cwd, dir))
if (runs.length === 0 && evals.length === 0) {
io.print("Usage: es improve harvest --runs <dir>[,<dir>] [--evals <dir>[,<dir>]] --out <file>")
return 2
}
const out = flag(args, "out")
const telemetry = await harvestTelemetry({ runs, evals })
const rendered = `${JSON.stringify(telemetry, null, 2)}\n`
if (out) {
const file = path.resolve(io.cwd, out)
await writeFile(file, rendered)
const rel = path.relative(io.cwd, file) || file
io.print(
`Harvested ${telemetry.runs.length} run(s) and ${telemetry.evals.length} eval(s) to ${rel} (telemetry v${telemetry.version}).`,
)
return 0
}
io.print(rendered.trimEnd())
return 0
}
export interface ImproveIO {
readonly print: (text: string) => void
readonly cwd: string
readonly confirm: ConfirmIO
}
/** Stage only the proposal dirs, commit on a topic branch and open a draft PR. Never pushes the base branch. */
export async function openProposalsPr(
root: string,
proposalDirs: readonly string[],
options: {
readonly base?: string
readonly date?: string
readonly run?: (argv: readonly string[]) => Promise<{ code: number; stdout: string; stderr: string }>
} = {},
): Promise<{ branch: string; url: string; staged: string[] }> {
const exec = options.run ?? ((argv: readonly string[]) => run([...argv], { cwd: root, timeoutMs: 60_000 }))
const auth = await exec(["gh", "auth", "status"])
if (auth.code !== 0) throw new Error("gh is not authenticated (gh auth status failed); cannot open a draft PR.")
const stamp = (options.date ?? new Date().toISOString().slice(0, 10)).replaceAll("-", "")
const branch = `improve/${stamp}`
const current = await git(root, ["rev-parse", "--abbrev-ref", "HEAD"])
const base = options.base ?? current.stdout.trim()
if (!base) throw new Error("cannot determine the PR base branch")
await git(root, ["checkout", "-b", branch])
const staged = [...proposalDirs]
staged.sort(compareStrings)
await git(root, ["add", "--", ...staged])
await git(root, ["commit", "-q", "-m", `Distilled improvement proposals (${staged.length})`])
await git(root, ["push", "--set-upstream", "origin", `${branch}:${branch}`])
const pr = await exec([
"gh",
"pr",
"create",
"--draft",
"--head",
branch,
"--base",
base,
"--title",
`Distilled improvement proposals (${stamp})`,
"--body",
prBody(root, staged),
])
const url = prUrl(pr.stdout)
if (pr.code !== 0 || !url) throw new Error("gh pr create failed")
return { branch, url, staged }
}
const prBody = (root: string, staged: readonly string[]): string => {
const entries = staged.map((dir) => `- ${path.relative(root, dir)}`)
return [
"Review-only proposals distilled from telemetry. Nothing is applied by merging this PR's branch alone;",
"a human applies what they review.",
"",
...entries,
].join("\n")
}
const prUrl = (stdout: string): string | undefined =>
stdout
.trim()
.split("\n")
.find((line) => /^https?:\/\//.test(line))
const readTelemetry = async (cwd: string, file: string): Promise<Telemetry> => {
let raw: unknown
try {
raw = JSON.parse(await readFile(path.resolve(cwd, file), "utf8"))
} catch (error) {
throw new Error(`Cannot read ${file}: ${error instanceof Error ? error.message : String(error)}`)
}
const parsed = TelemetrySchema.safeParse(raw)
if (!parsed.success)
throw new Error(`Invalid telemetry in ${file}: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`)
return parsed.data
}
const readThreshold = (args: Args, name: string): number | undefined => {
const raw = flag(args, name)
if (raw === undefined) return undefined
const value = Number(raw)
if (!Number.isInteger(value) || value <= 0) throw new Error(`Bad --${name}: takes a positive integer.`)
return value
}
const distillThresholds = (args: Args): DistillThresholds => {
const minOccurrences = readThreshold(args, "min-occurrences")
const minRuns = readThreshold(args, "min-runs")
return {
...(minOccurrences !== undefined ? { minOccurrences } : {}),
...(minRuns !== undefined ? { minRuns } : {}),
}
}
const renderStanding = async (
telemetry: Telemetry,
thresholds: DistillThresholds,
out: string,
): Promise<{
proposals: Proposal[]
standing: Proposal[]
dropped: Array<{ proposal: Proposal; reasons: string[] }>
written: WrittenProposal[]
droppedFile: string
}> => {
const proposals = await distillProposals(telemetry, thresholds)
const standing = undisposed(proposals, telemetry)
const dropped = disposedWithReasons(proposals, telemetry)
const rendered = new Map<string, Record<string, string>>()
const files = await Promise.all(standing.map((proposal) => renderProposalFiles(proposal.cluster)))
for (const [index, proposal] of standing.entries()) rendered.set(proposal.id, files[index]!.files)
const written = await writeProposals(out, standing, rendered)
const droppedFile = await writeDropped(out, dropped)
return { proposals, standing, dropped, written, droppedFile }
}
const openPr = async (
args: Args,
io: ImproveIO,
root: string,
written: WrittenProposal[],
lines: string[],
): Promise<void> => {
if (args.flags["open-pr"] !== true) return
if (!io.confirm.interactive) throw new NotInteractive("es improve distill --open-pr")
const base = flag(args, "base")
try {
const opened = await openProposalsPr(
root,
written.map((item) => item.dir),
{ ...(base ? { base } : {}) },
)
const target = base ?? "current branch"
lines.push(
`Draft PR: ${opened.url} (${opened.branch} → ${target}; ${opened.staged.length} proposal(s) staged, base never pushed).`,
)
} catch (error) {
throw new Error(`Could not open a draft PR: ${error instanceof Error ? error.message : String(error)}`)
}
}
export async function improveDistill(args: Args, io: ImproveIO, root: string): Promise<number> {
const telemetryFile = flag(args, "telemetry")
if (!telemetryFile) {
io.print("Usage: es improve distill --telemetry <file> --out <dir> [--open-pr] [--base <ref>]")
return 2
}
let telemetry: Telemetry
try {
telemetry = await readTelemetry(io.cwd, telemetryFile)
} catch (error) {
io.print(error instanceof Error ? error.message : String(error))
return 1
}
let thresholds: DistillThresholds
try {
thresholds = distillThresholds(args)
} catch (error) {
io.print(error instanceof Error ? error.message : String(error))
return 2
}
const out = flag(args, "out") ? path.resolve(io.cwd, flag(args, "out")!) : factoryLayout(root).improveProposals
const { proposals, standing, dropped, written, droppedFile } = await renderStanding(telemetry, thresholds, out)
const disposed = proposals.length - standing.length
const lines = [
`Distilled ${proposals.length} proposal(s) (${standing.length} standing, ${disposed} disposed by the gate):`,
...written.map((item) => ` ${item.kind} ${item.id} (${item.cluster.occurrences}× ${item.cluster.key})`),
// Dropped proposals are never silent: each is reported with its reason,
// and `dropped.json` records them all (S3.5, #136).
...(dropped.length > 0
? [
`Disposed by the gate (${dropped.length}):`,
...dropped.map(
(entry) =>
` ${entry.proposal.kind} ${entry.proposal.id} (${entry.proposal.cluster.key}): ${entry.reasons.join("; ")}`,
),
`Dropped details: ${path.relative(io.cwd, droppedFile) || droppedFile}.`,
]
: []),
`Wrote ${written.length} proposal(s) under ${path.relative(io.cwd, out) || out}. Nothing was applied.`,
]
try {
await openPr(args, io, root, written, lines)
} catch (error) {
if (error instanceof NotInteractive) throw error
io.print([...lines, error instanceof Error ? error.message : String(error)].join("\n"))
return 1
}
io.print(lines.join("\n"))
return 0
}
+97 -1
View File
@@ -2,21 +2,25 @@
// the terminal, then drive the seat headlessly until it settles. Both run on a
// factory run with a spend ceiling (decision 7): with none, `--max-usd N`
// starts a GRILL run with that provisional ceiling, confirmed with the passphrase.
import { mkdir, stat } from "node:fs/promises"
import { userInfo } from "node:os"
import path from "node:path"
import {
type Args,
allAudits,
describeTarget,
Factory,
factoryLayout,
flag,
gateRunner,
parseAuditTarget,
parseModelRef,
readAssessments,
readScoutPlan,
readScoutResult,
scoutPaths,
} from "@heretek-ai/es-core"
import { type Args, flag } from "./args.ts"
import {
DRIVERS,
driveHeadless,
@@ -308,3 +312,95 @@ export async function scoutShow(context: HumanContext, args: Args): Promise<numb
context.print('No scout in .factory/scout. Start one with /scout in OpenCode or `es scout "<feature>" --max-usd N`.')
return 1
}
/**
* Human-only `es research deep <query> --output <dir> --max-usd N`: start a
* research-only run in the output directory (created; no git repo needed)
* and drive the deep-researcher headlessly until REPORT.md completes at DONE.
*/
export async function researchDeepCommand(context: HumanContext, args: Args): Promise<number> {
const query = args.positionals.join(" ").trim()
if (!query) {
context.print(
'Usage: es research deep "<question>" --output <dir> --max-usd N [--driver <id>] [--model provider/model] [--max-turns N]',
)
return 2
}
const level = levelFor(context, args)
if (!level) return 2
// Validate inputs before touching the environment, so usage errors read
// the same with or without a driver installed.
const model = flag(args, "model")
if (model !== undefined && !parseModelRef(model)) {
context.print(`Bad --model "${model}": expected "provider/model" (both parts non-empty, no spaces).`)
return 2
}
const rawCeiling = flag(args, "max-usd")
const ceiling = rawCeiling === undefined ? Number.NaN : Number(rawCeiling)
if (!(ceiling > 0)) {
context.print("Deep research needs a spend ceiling. Give one: --max-usd <USD>.")
return 2
}
const driver = await driverFor(context, args)
if (!driver) return 2
const outDir = path.resolve(context.root, flag(args, "output") ?? ".")
await mkdir(outDir, { recursive: true })
const factory = new Factory(outDir, {
gates: gateRunner(context.stateDir ? { stateDir: context.stateDir } : {}),
...(context.stateDir ? { stateDir: context.stateDir } : {}),
})
const lines = [
`A research-only run starts in ${path.relative(context.root, outDir) || outDir} with a $${ceiling} ceiling.`,
"The deep-researcher runs thesis, antithesis and synthesis headlessly; seats halt when the spend reaches the ceiling.",
]
if (!(await confirmHuman(context.io, `Deep research: ${query}`, lines, context.stateDir))) {
context.print("Cancelled; nothing was started.")
return 1
}
const begun = await factory.beginResearchRun({ objective: query, ceilingUSD: ceiling }, `human:${user()}`)
context.print(`Research run ${begun.runId} started: "${query}".`)
const report = path.join(factoryLayout(outDir).research, "REPORT.md")
let outcome: HeadlessEvent | undefined
for await (const event of drain(
context,
level,
driveHeadless(
{
root: outDir,
driver,
...(turns(args) ? { maxTurns: turns(args)! } : {}),
...(context.stateDir ? { stateDir: context.stateDir } : {}),
...(model ? { model } : {}),
},
{
agent: "deep-researcher",
prompt: async () =>
// The coordinator flow is the deep-researcher seat's system prompt
// (single-sourced in core); this only adds the run-specific question
// and the headless note.
`${await factory.summary()}\nDeep research: ${query}. This is a headless run: no human will answer questions; follow your Flow through es_research_complete.`,
finished: async (current) =>
current.stage === "DONE" ? { type: "done", report: path.relative(context.root, report) } : undefined,
progress: async (current) => {
const stamp = async (file: string) =>
stat(file)
.then((info) => `${info.size}@${info.mtimeMs}`)
.catch(() => "-")
const research = factoryLayout(outDir).research
return JSON.stringify([
current.stage,
await stamp(path.join(research, "alpha.md")),
await stamp(path.join(research, "beta.md")),
await stamp(path.join(research, "REPORT.md")),
])
},
},
),
))
outcome = event
if (outcome?.type === "done") {
context.print(`Research complete: ${outcome.report}`)
return 0
}
return 1
}
+175 -58
View File
@@ -2,11 +2,13 @@
// resume, git hooks) require an interactive terminal and the human passphrase,
// which unlocks the passphrase-sealed human key; agent shells are denied
// these commands by policy as well.
import { readFile } from "node:fs/promises"
import { appendFile, readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import {
type Args,
aliasReuseRatio,
atomicWrite,
BOOLEAN_FLAGS,
brainstormPaths,
buildPlan,
candidateId,
@@ -19,6 +21,7 @@ import {
factoryLayout,
factorySummary,
findOneOffs,
flag,
formatDiagnostics,
gateRunner,
git,
@@ -27,10 +30,13 @@ import {
installServer,
LENSES,
LspManager,
listBrainstormRuns,
listRuns,
loadEsConfig,
loadHooks,
loadInterview,
parseArgs,
parseRunId,
parseSource,
readIdeas as readBrainstormIdeas,
readPlan as readBrainstormPlan,
@@ -44,6 +50,7 @@ import {
recordConsent,
renderBrainstorm,
renderGuide,
renderResearchRun,
researchOptions,
researchTools,
SlotLoop,
@@ -56,12 +63,21 @@ import {
writeArtifacts,
writeProfile,
} from "@heretek-ai/es-core"
import { type Args, flag, parseArgs } from "./args.ts"
import { gatesRun, installGitHooks } from "./gates.ts"
import { DRIVERS, LOG_LEVELS, logLevel, presentEvent, runHeadless } from "./headless.ts"
import {
checkCwd,
DRIVERS,
headlessExitCode,
LOG_LEVELS,
logLevel,
presentEvent,
runHeadless,
toJsonl,
} from "./headless.ts"
import {
approve,
configSet,
factoryInit,
type HumanContext,
rebaselineControl,
recordPr,
@@ -71,7 +87,8 @@ import {
trust,
waive,
} from "./human.ts"
import { auditCommand, auditDismiss, auditShow, scoutCommand, scoutShow } from "./jobs.ts"
import { improveDistill, improveHarvest } from "./improve.ts"
import { auditCommand, auditDismiss, auditShow, researchDeepCommand, scoutCommand, scoutShow } from "./jobs.ts"
import { keySeal, keyStatus } from "./key.ts"
import { serveStdio } from "./mcp.ts"
import { runsCommand, statusCommand } from "./status.ts"
@@ -90,19 +107,21 @@ Factory
runs [--all] [--json] Recent runs across your projects (* marks this one)
watch [--interval S] es status, redrawn every S seconds (default 2); q quits [terminal]
factory begin Start a run (normally done by /grill)
factory run --headless Drive the factory through a harness CLI, emitting JSON lines
factory init --preset <name> --issue <n> Seed a preset run from a GitHub issue [human, TTY]
factory run --headless Drive the factory through a harness CLI, emitting JSON lines [human]
[--driver opencode] [--max-turns N] [--log-level quiet|info|debug]
[--events jsonl] [--events-file <path>] [--turn-timeout S]
factory stop [reason] Create .factory/STOP (kill switch)
factory resume Clear a halt [human, TTY]
[--accept-drift] [--raise-ceiling USD] [--extend-runtime]
factory pr <url> Record a release PR opened by hand [human, TTY]
Checkpoints and exceptions [human, TTY]
approve <frontier|spec> Approve a checkpoint (shows hashes; asks for the passphrase)
trust [--show] Approve this project's gate commands by hash
rebaseline Accept hand edits to pinned control files (gates.json, config.json)
Checkpoints and exceptions
approve <frontier|spec> Approve a checkpoint (shows hashes; asks for the passphrase) [human, TTY]
trust [--show] Approve this project's gate commands by hash [human, TTY]
rebaseline Accept hand edits to pinned control files (gates.json, config.json) [human, TTY]
reseal [--sign] Verify engine sidecars (re-sign reviewed files) [human, TTY]
waive <rule> --reason "…" [--files <glob>] [--expires 7d] [--id <name>]
waive <rule> --reason "…" [--files <glob>] [--expires 7d] [--id <name>] [human, TTY]
key seal Create the passphrase-sealed human key [human, TTY]
key status Show the human key fingerprint
@@ -115,15 +134,20 @@ Other
research search <query> Search with the configured provider
research fetch <url> Fetch and cache a source (prints its sha256)
research audit [file] [--prune] Epistemic audit of a research report
research export [--out <file>] Signed research brief (.factory/research/brief.pcrb.json)
research render --format md|html [--out <file>] Readable dossier (Markdown or self-contained HTML)
research export [--out <file>] Signed research brief (.factory/research/brief.pcrb.json) [human, TTY]
research verify-brief <file> [--allow-unverifiable] Check a brief's sources, manifest, signature, quotes
research retract <sha256> --event retracted|revised [--note "…"] Degrade claims citing a source [human, TTY]
research deep "<question>" --output <dir> --max-usd N Adversarial deep research to DONE [human, TTY]
brainstorm plan "<idea>" Freeze a lens fan-out plan (.factory/brainstorm)
[--lenses a,b] [--ideas N] [--shortlist N] [--force]
brainstorm show [--json] Show the plan/progress or the finished shortlist
brainstorm lenses List the built-in divergent lenses
harvest show [--json] Show the teardown plan/progress or the report
harvest scan <source> Scan one source (local path, git URL, github:owner/repo, npm:name)
improve harvest --runs <dir>[,<dir>] [--evals <dir>] --out <file> Harvest read-only telemetry from runs and evals
improve distill --telemetry <file> --out <dir> [--open-pr] Cluster telemetry into reviewable proposals
[--open-pr opens a draft PR from a topic branch; human-run, refused without a terminal]
design [status] Design interview progress (resumes from .factory/design)
design render Re-render tokens.css + STYLE_GUIDE.md from tokens.json
design check Fail on render drift, invalid tokens or one-off mints
@@ -177,24 +201,6 @@ async function projectRoot(cwd: string): Promise<string> {
return top.code === 0 ? top.stdout.trim() : cwd
}
const BOOLEAN_FLAGS = [
"full",
"staged",
"json",
"show",
"headless",
"accept-drift",
"extend-runtime",
"uninstall",
"help",
"force",
"allow-unverifiable",
"global",
"open-only",
"sign",
"all",
]
export async function main(argv: readonly string[], io: MainIO): Promise<number> {
const args = parseArgs(argv, BOOLEAN_FLAGS)
const [command, sub, ...rest] = args.positionals
@@ -263,6 +269,7 @@ export async function main(argv: readonly string[], io: MainIO): Promise<number>
return 2
}
case "research": {
if (sub === "deep") return await researchDeepCommand(context, subArgs(2))
if (sub === "retract") return await retract(context, subArgs(2))
if (sub === "export") {
const out = flag(args, "out")
@@ -273,7 +280,12 @@ export async function main(argv: readonly string[], io: MainIO): Promise<number>
[
`Signs the brief with the human key (${outFile ? path.relative(root, outFile) : ".factory/research/brief.pcrb.json"}).`,
],
io.stateDir,
{
...(io.stateDir ? { stateDir: io.stateDir } : {}),
root,
stage: "export",
channel: "cli",
},
)
if (!signer) {
io.print("Cancelled; nothing was exported.")
@@ -282,6 +294,7 @@ export async function main(argv: readonly string[], io: MainIO): Promise<number>
const { file, brief } = await exportBrief(root, {
signer,
...(outFile ? { out: outFile } : {}),
...(io.stateDir ? { stateDir: io.stateDir } : {}),
})
const witnessed = brief.claims.filter((claim) => claim.witness.ok).length
io.print(
@@ -330,26 +343,55 @@ export async function main(argv: readonly string[], io: MainIO): Promise<number>
...(config ? researchOptions(config) : {}),
stateDir: io.stateDir,
policy: () => Promise.resolve({ root, ...(io.stateDir ? { stateDir: io.stateDir } : {}) }),
// Explicit construction-time operator (#99): the per-call agent is
// never trusted for this, so the CLI names it "cli", not "human".
operator: "human",
})
const tool = (name: string) => tools.find((item) => item.name === name)!
if (sub === "search" && rest.length) {
io.print(await tool("es_research_search").execute({ query: rest.join(" ") }, { agent: "human" }))
io.print(await tool("es_research_search").execute({ query: rest.join(" ") }, { agent: "cli" }))
return 0
}
if (sub === "fetch" && rest[0]) {
io.print((await tool("es_research_fetch").execute({ url: rest[0] }, { agent: "human" })).split("\n---\n")[0]!)
io.print((await tool("es_research_fetch").execute({ url: rest[0] }, { agent: "cli" })).split("\n---\n")[0]!)
return 0
}
if (sub === "audit") {
const text = await tool("es_research_audit").execute(
{ ...(rest[0] ? { path: rest[0] } : {}), prune: args.flags.prune === true },
{ agent: "human" },
{ agent: "cli" },
)
io.print(text)
return /Audit passed/.test(text) || /Pruned/.test(text) ? 0 : 1
}
if (sub === "render") {
const format = flag(args, "format") ?? "md"
if (format !== "md" && format !== "html") {
io.print("Usage: es research render --format md|html [--out <file>]")
return 2
}
let text: string
try {
text = await renderResearchRun(root, {
format,
...(io.stateDir ? { stateDir: io.stateDir } : {}),
})
} catch (error) {
io.print(error instanceof Error ? error.message : String(error))
return 1
}
const out = flag(args, "out")
if (out) {
const file = path.resolve(io.cwd, out)
await writeFile(file, text)
io.print(`Rendered ${path.relative(root, file) || file} (${format}, ${text.length} bytes)`)
return 0
}
io.print(text)
return 0
}
io.print(
"Usage: es research search <query> | fetch <url> | audit [file] [--prune] | export [--out <file>] | verify-brief <file> | retract <sha256> --event retracted|revised",
'Usage: es research search <query> | fetch <url> | audit [file] [--prune] | render --format md|html [--out <file>] | export [--out <file>] | verify-brief <file> | retract <sha256> --event retracted|revised | deep "<question>" --output <dir> --max-usd N',
)
return 2
}
@@ -405,19 +447,26 @@ export async function main(argv: readonly string[], io: MainIO): Promise<number>
return 0
}
if (sub === "show" || sub === undefined) {
const result = await readBrainstormResult(root).catch(() => undefined)
let run: string
try {
run = parseRunId(rest[0])
} catch (error) {
io.print(error instanceof Error ? error.message : String(error))
return 2
}
const result = await readBrainstormResult(root, run).catch(() => undefined)
if (result) {
io.print(args.flags.json === true ? JSON.stringify(result, null, 2) : renderBrainstorm(result))
return 0
}
const plan = await readBrainstormPlan(root).catch(() => undefined)
const plan = await readBrainstormPlan(root, run).catch(() => undefined)
if (plan) {
const ideas = await readBrainstormIdeas(root).catch(() => [])
const scores = await readBrainstormScores(root).catch(() => [])
const ideas = await readBrainstormIdeas(root, run).catch(() => [])
const scores = await readBrainstormScores(root, run).catch(() => [])
const survivors = ideas.filter((idea) => !idea.duplicateOf)
io.print(
[
`Brainstorm in progress: "${plan.brief.idea}"`,
`Brainstorm in progress (run "${run}"): "${plan.brief.idea}"`,
`Lenses: ${plan.lenses.join(", ")}`,
`Ideas: ${survivors.length} surviving (${ideas.length - survivors.length} duplicate(s)) · scored ${scores.length}/${survivors.length}`,
`Coverage: ${plan.lenses.map((lens) => `${lens} ${survivors.filter((idea) => idea.lens === lens).length}`).join(" · ")}`,
@@ -425,12 +474,15 @@ export async function main(argv: readonly string[], io: MainIO): Promise<number>
)
return 0
}
const runs = await listBrainstormRuns(root).catch(() => [] as string[])
io.print(
`No brainstorm in ${paths.dir}. Start one with /brainstorm in OpenCode, or \`es brainstorm plan "<idea>"\`.`,
runs.length
? `No brainstorm run "${run}" in ${paths.dir}. Runs: ${runs.join(", ")}.`
: `No brainstorm in ${paths.dir}. Start one with /brainstorm in OpenCode, or \`es brainstorm plan "<idea>"\`.`,
)
return 1
}
io.print('Usage: es brainstorm plan "<idea>" | show [--json] | lenses')
io.print('Usage: es brainstorm plan "<idea>" | show [run] [--json] | lenses')
return 2
}
case "harvest": {
@@ -469,21 +521,29 @@ export async function main(argv: readonly string[], io: MainIO): Promise<number>
}
}
if (sub === "show" || sub === undefined) {
const result = await readHarvestResult(root).catch(() => undefined)
let run: string
try {
run = parseRunId(rest[0])
} catch (error) {
io.print(error instanceof Error ? error.message : String(error))
return 2
}
const runPaths = harvestPaths(root, run)
const result = await readHarvestResult(root, run).catch(() => undefined)
if (result) {
const report = await readFile(paths.report, "utf8").catch(() => undefined)
const report = await readFile(runPaths.report, "utf8").catch(() => undefined)
io.print(
args.flags.json === true ? JSON.stringify(result, null, 2) : (report ?? `${paths.report} is missing`),
args.flags.json === true ? JSON.stringify(result, null, 2) : (report ?? `${runPaths.report} is missing`),
)
return 0
}
const plan = await readHarvestPlan(root).catch(() => undefined)
const plan = await readHarvestPlan(root, run).catch(() => undefined)
if (plan) {
const profiles = await readHarvestProfiles(root).catch(() => [])
const profiles = await readHarvestProfiles(root, run).catch(() => [])
const scanned = new Set(profiles.map((profile) => profile.id))
io.print(
[
`Darkharvest in progress: ${plan.objective}`,
`Darkharvest in progress (run "${run}"): ${plan.objective}`,
`Candidates: ${plan.candidates.map((candidate) => `${candidate.id}${scanned.has(candidate.id) ? " ✓" : ""}`).join(", ")}`,
`Scanned ${profiles.length}/${plan.candidates.length} · read budget ${plan.readTokensPerCandidate} tokens per candidate`,
].join("\n"),
@@ -495,7 +555,7 @@ export async function main(argv: readonly string[], io: MainIO): Promise<number>
)
return 1
}
io.print("Usage: es harvest show [--json] | scan <source>")
io.print("Usage: es harvest show [run] [--json] | scan <source>")
return 2
}
case "design": {
@@ -601,7 +661,15 @@ export async function main(argv: readonly string[], io: MainIO): Promise<number>
...server.install.packages.map((pkg) => `${pkg.name}@${pkg.version} ${pkg.integrity}`),
"Verified against the pinned sha512; installed with scripts disabled.",
]
if (!(await confirmHuman(io.confirm, `Install the ${id} language server`, lines, io.stateDir))) return 1
if (
!(await confirmHuman(io.confirm, `Install the ${id} language server`, lines, {
...(io.stateDir ? { stateDir: io.stateDir } : {}),
root,
stage: "lsp-install",
channel: "cli",
}))
)
return 1
await recordConsent(id!, true, io.stateDir)
io.print(`Installed ${await installServer(server, io.stateDir ? { stateDir: io.stateDir } : {})}`)
return 0
@@ -626,6 +694,15 @@ export async function main(argv: readonly string[], io: MainIO): Promise<number>
await manager.stopAll()
}
}
case "improve": {
if (sub === "harvest") return await improveHarvest(subArgs(2), { print: io.print, cwd: io.cwd })
if (sub === "distill")
return await improveDistill(subArgs(2), { print: io.print, cwd: io.cwd, confirm: io.confirm }, root)
io.print(
"Usage: es improve harvest --runs <dir>[,<dir>] [--evals <dir>] --out <file> | distill --telemetry <file> --out <dir> [--open-pr]",
)
return 2
}
case "hooks": {
const engine = await HookEngine.create({
root,
@@ -683,6 +760,7 @@ export async function main(argv: readonly string[], io: MainIO): Promise<number>
if (sub === "status") return await statusCommand({ root, stateDir: userState, print: io.print }, args)
if (sub === "resume") return await resume(context, subArgs(2))
if (sub === "pr") return await recordPr(context, subArgs(2))
if (sub === "init") return await factoryInit(context, subArgs(2))
if (sub === "stop") {
await atomicWrite(factoryLayout(root).stop, `${rest.join(" ") || "stopped from the CLI"}\n`)
io.print(
@@ -707,16 +785,55 @@ export async function main(argv: readonly string[], io: MainIO): Promise<number>
io.print(`Unknown --log-level. Use one of: ${LOG_LEVELS.join(", ")} (default info).`)
return 2
}
const stream = flag(args, "events")
if (stream !== undefined && stream !== "jsonl") {
io.print(`Unknown --events. Use: jsonl (a versioned event per line).`)
return 2
}
const eventsFile = flag(args, "events-file")
const timeoutRaw = flag(args, "turn-timeout")
const timeoutSec = timeoutRaw === undefined ? undefined : Number(timeoutRaw)
if (timeoutSec !== undefined && !(Number.isFinite(timeoutSec) && timeoutSec > 0)) {
io.print(`Bad --turn-timeout. Use seconds, e.g. --turn-timeout 600.`)
return 2
}
const refusal = await checkCwd(root)
if (refusal !== undefined) {
io.print(refusal)
return 2
}
// SIGINT/SIGTERM cancel the current turn but leave the run
// resumable: the driver sees the abort, the loop emits `cancelled`
// (exit 130), and no halt is written.
const stop = new AbortController()
const onSignal = () => stop.abort()
process.once("SIGINT", onSignal)
process.once("SIGTERM", onSignal)
let code = 0
for await (const event of runHeadless({
root,
driver,
...(flag(args, "max-turns") ? { maxTurns: Number(flag(args, "max-turns")) } : {}),
...(io.stateDir ? { stateDir: io.stateDir } : {}),
})) {
const shown = presentEvent(event, level)
if (shown !== undefined) io.print(JSON.stringify(shown))
if (["error", "halted", "stalled", "turn-cap"].includes(event.type)) code = 1
let runId = "none"
try {
for await (const event of runHeadless({
root,
driver,
...(flag(args, "max-turns") ? { maxTurns: Number(flag(args, "max-turns")) } : {}),
...(io.stateDir ? { stateDir: io.stateDir } : {}),
signal: stop.signal,
...(timeoutSec === undefined ? {} : { turnTimeoutMs: Math.round(timeoutSec * 1000) }),
})) {
if (event.type === "start") runId = event.runId
if (stream === "jsonl") {
const line = JSON.stringify(toJsonl(runId, event))
if (eventsFile) await appendFile(eventsFile, `${line}\n`)
else io.print(line)
} else {
const shown = presentEvent(event, level)
if (shown !== undefined) io.print(JSON.stringify(shown))
}
code = Math.max(code, headlessExitCode(event.type))
}
} finally {
process.removeListener("SIGINT", onSignal)
process.removeListener("SIGTERM", onSignal)
}
return code
}
+20 -8
View File
@@ -1,9 +1,11 @@
// Coarse MCP server (stdio, newline-delimited JSON-RPC 2.0) exposing the
// harness-neutral factory operations to harnesses without a native plugin and
// to headless runs. It has no approve, trust, waive or resume tool: those are
// human-only. Caller identity is the `agent` argument (or ES_AGENT), which the
// harness adapter sets per agent; over plain MCP it is ADVISORY, and the
// capability matrix says so.
// human-only. Caller identity is PINNED, never piped: the harness adapter sets
// one agent id per server in its environment (ES_MCP_AGENT, else the legacy
// ES_AGENT/defaultAgent, both written by the human in the adapter config), and
// the per-call `agent` argument is ignored. Without an adapter identity the
// caller is "mcp", which maps to no seat, so every seat-checked tool refuses.
import { stateDir as defaultStateDir, esTools, Factory, gateRunner } from "@heretek-ai/es-core"
import { VERSION } from "./version.ts"
@@ -14,11 +16,17 @@ export interface McpOptions {
readonly stateDir?: string
readonly defaultAgent?: string
readonly version?: string
/** Server environment for the pinned identity (defaults to process.env). */
readonly env?: NodeJS.ProcessEnv
}
type Json = Record<string, any>
export function createMcpServer(options: McpOptions) {
const env = options.env ?? process.env
// Pinned at construction: the adapter's environment, never the caller's
// arguments. An agent piping JSON-RPC into this server cannot claim a seat.
const caller = env.ES_MCP_AGENT || options.defaultAgent || "mcp"
const factory = new Factory(options.root, {
gates: gateRunner(options.stateDir ? { stateDir: options.stateDir } : {}),
...(options.stateDir ? { stateDir: options.stateDir } : {}),
@@ -27,12 +35,16 @@ export function createMcpServer(options: McpOptions) {
const byName = new Map(tools.map((tool) => [tool.name.replace(/^es_/, ""), tool]))
const listed = [...byName].map(([name, tool]) => ({
name,
description: `${tool.description}${name === "status" || name === "spec_validate" ? "" : " Pass your agent ID as `agent`."}`,
description: `${tool.description}${name === "status" || name === "spec_validate" ? "" : " Caller identity is pinned by the adapter (ES_MCP_AGENT); a per-call `agent` argument is accepted but ignored."}`,
inputSchema: {
...tool.input,
properties: {
...(tool.input.properties as Json),
agent: { type: "string", description: "Calling agent ID (e.g. es-programmer). Advisory over MCP." },
agent: {
type: "string",
description:
"Ignored: the caller is the adapter's pinned ES_MCP_AGENT (or the default agent), never this argument.",
},
},
},
}))
@@ -49,7 +61,7 @@ export function createMcpServer(options: McpOptions) {
capabilities: { tools: { listChanged: false } },
serverInfo: { name: "epistemic-swarm", version: options.version ?? VERSION },
instructions:
"Epistemic Swarm factory operations. Approvals, trust, waivers and resume are human-only (TUI or `es` CLI) and have no MCP tool. The `agent` argument identifies the caller ADVISORY: the harness adapter, not this protocol, establishes it.",
"Epistemic Swarm factory operations. Approvals, trust, waivers and resume are human-only (TUI or `es` CLI) and have no MCP tool. Caller identity is pinned: the adapter sets ES_MCP_AGENT in this server's environment (one server per agent); without it the caller is `mcp`, which holds no seat, so seat-checked tools refuse. A per-call `agent` argument is ignored.",
})
case "ping":
return reply({})
@@ -58,9 +70,9 @@ export function createMcpServer(options: McpOptions) {
case "tools/call": {
const tool = byName.get(String(params?.name ?? ""))
if (!tool) return fail(-32602, `Unknown tool: ${params?.name}`)
const { agent, ...input } = (params?.arguments ?? {}) as Json
const { agent: _ignored, ...input } = (params?.arguments ?? {}) as Json
try {
const text = await tool.execute(input, { agent: String(agent ?? options.defaultAgent ?? "mcp") })
const text = await tool.execute(input, { agent: caller })
return reply({ content: [{ type: "text", text }] })
} catch (error) {
return reply({
+2 -1
View File
@@ -1,6 +1,8 @@
// `es status` and `es runs` (1.1.3, #58/#61): which run, in which project,
// is it working, waiting, stuck or done. The human view leads with one plain
// sentence; `--json` gives the same liveness to scripts and monitors.
import type { Args } from "@heretek-ai/es-core"
import {
ago,
Factory,
@@ -13,7 +15,6 @@ import {
type RunIndexEntry,
readLiveness,
} from "@heretek-ai/es-core"
import type { Args } from "./args.ts"
export interface StatusContext {
readonly root: string
+49 -5
View File
@@ -6,7 +6,15 @@
// passphrase. Signing actions use the unlocked key; the rest use the unlock
// as proof that the human is at the keyboard.
import { createInterface } from "node:readline"
import { HumanKeyError, type HumanSigner, unlockHumanKey } from "@heretek-ai/es-core"
import {
checkApprovalAttempts,
stateDir as defaultStateDir,
HumanKeyError,
type HumanSigner,
noteApprovalSuccess,
noteWrongPassphrase,
unlockHumanKey,
} from "@heretek-ai/es-core"
export interface ConfirmIO {
readonly interactive: boolean
@@ -65,23 +73,59 @@ export class NotInteractive extends Error {
/**
* Show what the action does, then ask for the passphrase. Returns the unlocked
* human key, or undefined when the human cancels or the passphrase is wrong
* (the reason is written to the terminal).
* human key, or undefined when the human cancels, the passphrase is wrong, or
* the state dir is locked out (the reason is written to the terminal).
*
* ADR 0002 I4: at most 5 wrong passphrases per 10 minutes per state dir,
* shared with the TUI path (the same attempt file). A correct passphrase
* clears the count.
*/
export interface ConfirmOptions {
/** Override for the user-global state dir (the limit is per state dir). */
readonly stateDir?: string
/** Project root whose audit log records a lockout (without it failures are not counted). */
readonly root?: string
/** Stage (or action label) named in the lockout audit entry. */
readonly stage?: string
/** Channel named in the lockout audit entry. */
readonly channel?: string
}
export async function confirmHuman(
io: ConfirmIO,
action: string,
lines: readonly string[],
stateDir?: string,
stateDirOrOptions?: string | ConfirmOptions,
): Promise<HumanSigner | undefined> {
const options: ConfirmOptions =
stateDirOrOptions === undefined
? {}
: typeof stateDirOrOptions === "string"
? { stateDir: stateDirOrOptions }
: stateDirOrOptions
if (!io.interactive) throw new NotInteractive(action)
const dir = options.stateDir ?? defaultStateDir()
const gate = await checkApprovalAttempts(dir)
if (!gate.allowed) {
io.write(`Too many wrong passphrases. Try again in ${gate.retryAfterSec ?? 0}s (the lockout is audited).\n`)
return undefined
}
io.write(`\n${action}\n${"─".repeat(Math.min(72, action.length + 8))}\n${lines.join("\n")}\n\n`)
const passphrase = await io.readSecret("Your Epistemic Swarm passphrase (empty cancels): ")
if (!passphrase) return undefined
try {
return await unlockHumanKey(passphrase, stateDir)
const signer = await unlockHumanKey(passphrase, dir)
await noteApprovalSuccess(dir)
return signer
} catch (error) {
if (!(error instanceof HumanKeyError)) throw error
if (error.message === "wrong passphrase" && options.root !== undefined)
await noteWrongPassphrase({
dir,
root: options.root,
stage: options.stage ?? action,
channel: options.channel ?? "cli",
})
io.write(`${error.message}\n`)
return undefined
}
+1 -1
View File
@@ -1,2 +1,2 @@
/** The `es` CLI version. Single source: kept equal to the package version by test/cli.test.ts. */
export const VERSION = "1.1.4"
export const VERSION = "1.6.0"
+1 -2
View File
@@ -2,8 +2,7 @@
// terminal, for following a run without the TUI (a headless run, another
// project). q or ctrl+c quits. It needs a terminal: an agent shell has none,
// so an agent can never hang on it.
import type { Args } from "./args.ts"
import { flag } from "./args.ts"
import { type Args, flag } from "@heretek-ai/es-core"
import { type StatusContext, statusSnapshot } from "./status.ts"
export interface WatchIO {
+795 -2
View File
@@ -7,14 +7,20 @@ import {
Factory,
factoryLayout,
gateRunner,
HUMAN_VERBS,
loadPrompt,
parseArgs,
readApproval,
recentAuditEntries,
researchSourcesDir,
SourceCache,
sealHumanKey,
stringifyFrontmatter,
} from "@heretek-ai/es-core"
import { parseArgs, parseExpiry } from "../src/args.ts"
import { parseExpiry } from "../src/args.ts"
import { type HarnessDriver, type HeadlessEvent, presentEvent, runHeadless } from "../src/headless.ts"
import { main } from "../src/main.ts"
import { openProposalsPr } from "../src/improve.ts"
import { commandHelp, main } from "../src/main.ts"
import { createMcpServer } from "../src/mcp.ts"
import { type ConfirmIO, confirmHuman, NotInteractive } from "../src/tty.ts"
import { VERSION } from "../src/version.ts"
@@ -91,6 +97,62 @@ describe("args", () => {
})
})
describe("HELP human-only markers match HUMAN_VERBS (#97)", () => {
// Every human-only command line carries an explicit [human, TTY] (or
// [human]) marker; section headers do not count. Placeholders (<…>, […],
// "…") are not literal sub-verbs and are skipped.
const literal = (word: string | undefined): string | undefined =>
word !== undefined && /^[A-Za-z][\w.-]*$/.test(word) ? word : undefined
const commands = () => {
const help = commandHelp("no-such-command")
const rows: Array<{ verb: string; sub: string | undefined; marked: boolean; line: string }> = []
for (const line of help.split("\n")) {
const match = /^ {2}(\S+)(?:\s+(\S+))?/.exec(line)
if (!match) continue
rows.push({
verb: match[1]!,
sub: literal(match[2]),
marked: /\[human[^\]]*\]/.test(line),
line: line.trim(),
})
}
return rows
}
test("every marked verb is human-only, and every human-only verb is marked", () => {
const rows = commands()
expect(rows.length).toBeGreaterThan(20)
const names = new Set(HUMAN_VERBS.map(([name]) => name))
for (const row of rows.filter((item) => item.marked)) {
expect([row.line, names.has(row.verb)]).toEqual([row.line, true])
const [, predicate] = HUMAN_VERBS.find(([name]) => name === row.verb)!
if (row.sub !== undefined && predicate !== undefined)
expect([row.line, predicate(row.sub)]).toEqual([row.line, true])
}
// Bare "resume" has no top-level CLI command (only factory resume); the
// rule stays as over-approximation for other binaries. Every verb HELP
// documents as a command must show a marker.
for (const [name] of HUMAN_VERBS)
if (name !== "resume" && rows.some((row) => row.verb === name))
expect([name, rows.some((row) => row.marked && row.verb === name)]).toEqual([name, true])
})
test("a literal sub-verb the rule calls human-only is never left unmarked", () => {
const rows = commands()
const unmarked: string[] = []
for (const [name, predicate] of HUMAN_VERBS) {
if (predicate === undefined) continue
const literalSubs = new Set(
rows.filter((row) => row.verb === name && row.sub !== undefined).map((row) => row.sub!),
)
for (const sub of literalSubs)
if (predicate(sub) && !rows.some((row) => row.verb === name && row.sub === sub && row.marked))
unmarked.push(`${name} ${sub}`)
}
expect(unmarked).toEqual([])
})
})
describe("--help (#59)", () => {
test("`es <command> --help` prints that command's usage; any --help value means help", async () => {
const reseal = await run(["reseal", "--help"])
@@ -158,6 +220,114 @@ describe("human-only confirmation", () => {
expect(granted.code).toBe(0)
expect(granted.out).toContain("granted")
})
test("an edit between preview and sign is refused: the CLI signs what it previewed (ADR 0002 I3)", async () => {
await mkdir(path.join(root, ".factory/specs/alpha"), { recursive: true })
await writeFile(
path.join(root, ".factory/roadmap.json"),
JSON.stringify({ version: 1, title: "Greeting", phases: [{ id: "alpha", title: "Phase alpha" }] }),
)
const goal = path.join(root, ".factory/specs/alpha/GOAL.md")
await writeFile(
goal,
stringifyFrontmatter(
{
phase: "alpha",
title: "Phase alpha",
acceptance: [{ kind: "file", id: "impl", description: "implementation exists", path: "src/alpha.ts" }],
},
"Implement alpha.\n",
),
)
// A seat edits GOAL.md while the human types the passphrase: the sign
// must refuse instead of signing the edit.
const output: string[] = []
const io: ConfirmIO = {
interactive: true,
write: (text) => output.push(text),
readLine: async () => "",
readSecret: async () => {
await writeFile(goal, `${await readFile(goal, "utf8")}\n<!-- seat edit -->\n`)
return PASSPHRASE
},
}
const result = await run(["approve", "spec"], io)
expect(result.code).not.toBe(0)
expect(result.out).toContain("What was previewed changed")
expect(await readApproval(root, "spec")).toBeUndefined()
})
test("a preview older than 120 s is refused (ADR 0002 I3 TTL)", async () => {
await grilledFrontier()
const realNow = Date.now
let now = 1_700_000_000_000
Date.now = () => now
try {
const io = human()
io.readSecret = async () => {
now += 121_000
return PASSPHRASE
}
const result = await run(["approve", "frontier"], io)
expect(result.code).toBe(1)
expect(result.out).toMatch(/expired|again/)
expect(await readApproval(root, "frontier")).toBeUndefined()
} finally {
Date.now = realNow
}
})
test("five wrong passphrases lock out the sixth without prompting, audited with channel cli (#143)", async () => {
const output: string[] = []
let reads = 0
const io: ConfirmIO = {
interactive: true,
write: (text) => output.push(text),
readLine: async () => "",
readSecret: async () => {
reads++
return "wrong-passphrase"
},
}
const confirm = () => confirmHuman(io, "Approve spec as tester", [], { stateDir: state, root, stage: "spec" })
for (let i = 0; i < 5; i++) expect(await confirm()).toBeUndefined()
expect(reads).toBe(5)
expect(await confirm()).toBeUndefined()
expect(reads).toBe(5)
expect(output.join("")).toMatch(/Try again in \d+s/)
const lockout = (await recentAuditEntries(root, 10)).find((entry) => entry.action === "approval.lockout")
expect(lockout?.payload).toMatchObject({ stage: "spec", channel: "cli" })
})
test("a correct passphrase resets the attempt count", async () => {
const output: string[] = []
let answer = "wrong-passphrase"
const io: ConfirmIO = {
interactive: true,
write: (text) => output.push(text),
readLine: async () => "",
readSecret: async () => answer,
}
const confirm = () => confirmHuman(io, "Approve spec as tester", [], { stateDir: state, root, stage: "spec" })
expect(await confirm()).toBeUndefined()
expect(await confirm()).toBeUndefined()
answer = PASSPHRASE
expect(await confirm()).toBeDefined()
// The count reset: five more wrongs are needed before the next lockout.
answer = "wrong-passphrase"
let reads = 0
const counting: ConfirmIO = {
...io,
readSecret: async () => {
reads++
return answer
},
}
const confirmCounting = () =>
confirmHuman(counting, "Approve spec as tester", [], { stateDir: state, root, stage: "spec" })
for (let i = 0; i < 5; i++) expect(await confirmCounting()).toBeUndefined()
expect(reads).toBe(5)
})
})
describe("es key", () => {
@@ -256,6 +426,132 @@ describe("research brief and retractions", () => {
expect(recorded.out).toContain("1 claim(s) citing")
expect((await ClaimStore.load(root)).citing(source).map((claim) => claim.status)).toEqual(["STALE"])
})
test("render is agent-safe: md and html to stdout or a file", async () => {
const source = await researched()
expect((await run(["research", "render", "--format", "bogus"])).code).toBe(2)
const md = await run(["research", "render", "--format", "md"])
expect(md.code).toBe(0)
expect(md.out).toContain("# Research dossier: cli")
expect(md.out).toContain("Briefs are signed.")
expect(md.out).toContain(source.slice(0, 12))
const html = await run(["research", "render", "--format", "html", "--out", "dossier.html"])
expect(html.code).toBe(0)
expect(html.out).toContain("Rendered dossier.html (html,")
const file = await Bun.file(path.join(root, "dossier.html")).text()
expect(file).toContain("<!doctype html>")
expect(file).not.toContain("<script")
expect((await run(["research", "render"])).code).toBe(0)
})
})
describe("es research deep (headless deep research)", () => {
test("usage without side effects; a ceiling needs a terminal", async () => {
expect((await run(["research", "deep", "--help"])).code).toBe(0)
expect(await exists(factoryLayout(root).dir)).toBe(false)
const bare = await run(["research", "deep"])
expect([bare.code, bare.out]).toEqual([2, expect.stringContaining("--max-usd N")])
const noCeiling = await run(["research", "deep", "what queue", "--output", "out"])
expect([noCeiling.code, noCeiling.out]).toEqual([2, expect.stringContaining("--max-usd")])
const badModel = await run([
"research",
"deep",
"what queue",
"--output",
"out",
"--max-usd",
"5",
"--model",
"nope",
])
expect([badModel.code, badModel.out]).toEqual([2, expect.stringContaining("provider/model")])
expect(await exists(factoryLayout(root).dir)).toBe(false)
const { DRIVERS } = await import("../src/headless.ts")
DRIVERS.fake = {
id: "fake",
available: async () => true,
async *turn(_input: unknown) {
yield { text: "unused" }
return "ses_fake"
},
}
try {
// No TTY: the ceiling confirmation refuses before anything is created.
// The fake driver pins the driver check, so this reads the same with
// or without a harness binary installed.
expect(
(await run(["research", "deep", "what queue", "--output", "out", "--max-usd", "5", "--driver", "fake"])).code,
).toBe(3)
expect(await exists(path.join(root, "out", ".factory"))).toBe(false)
} finally {
delete DRIVERS.fake
}
})
test("a fake driver plays the coordinator flow to DONE", async () => {
const { DRIVERS } = await import("../src/headless.ts")
const { Factory, gateRunner, researchTools } = await import("@heretek-ai/es-core")
const out = path.join(root, "out")
const stubFetch = (async () =>
new Response("<p>Queues decouple workers from producers durably.</p>", {
headers: { "content-type": "text/html" },
})) as unknown as typeof fetch
const prompts: string[] = []
DRIVERS.fake = {
id: "fake",
available: async () => true,
async *turn({ prompt }: { prompt: string }) {
yield { text: "working" }
prompts.push(prompt)
// Alpha gathers, beta finds nothing, the synthesizer resolves, the
// coordinator completes — through the real tools and the real gate.
const tools = researchTools({
root: out,
fetch: stubFetch,
stateDir: state,
policy: async () => ({ root: out }),
})
const fetched = await tools
.find((tool) => tool.name === "es_research_fetch")!
.execute({ url: "https://192.0.2.1/queues" }, { agent: "es-research-alpha" })
const sha = /sha256:([0-9a-f]{64})/.exec(fetched)?.[1]
await writeFile(
path.join(out, ".factory/research/alpha.md"),
`# Alpha\n\n- Queues decouple workers [VERIFIED: sha256:${sha} "decouple workers from producers"]\n`,
)
await writeFile(
path.join(out, ".factory/research/beta.md"),
"# Beta\n\n- No counter-evidence cached [NEGATIVE_KNOWLEDGE: searched for queue outages]\n",
)
await writeFile(
path.join(out, ".factory/research/REPORT.md"),
`# Report\n\n- Queues decouple workers [VERIFIED: sha256:${sha} "decouple workers from producers"]\n`,
)
await new Factory(out, { gates: gateRunner({ stateDir: state }), stateDir: state }).completeResearch(
"deep-researcher",
)
return "ses_fake"
},
}
try {
const driven = await run(
["research", "deep", "what queue", "--output", "out", "--max-usd", "5", "--driver", "fake"],
human(),
)
expect(driven.code).toBe(0)
expect(driven.out).toContain("Research run run-")
expect(driven.out).toContain("Research complete: out/.factory/research/REPORT.md")
expect(prompts[0]).toContain("Deep research: what queue")
// The flow is the deep-researcher seat's system prompt (opencode/src/agents.ts);
// the message carries only the run, the question and the headless note.
expect(prompts[0]).toContain("headless run")
expect(prompts[0]).not.toContain((await loadPrompt("deep-researcher")).body.trim())
const done = await new Factory(out, { gates: gateRunner({ stateDir: state }), stateDir: state }).read()
expect(done).toMatchObject({ mode: "research", stage: "DONE" })
} finally {
delete DRIVERS.fake
}
})
})
describe("es config set", () => {
@@ -419,6 +715,263 @@ describe("gates and audit", () => {
})
})
describe("es improve harvest (#135)", () => {
const improveFixtures = path.join(import.meta.dir, "..", "..", "core", "test", "fixtures", "improve")
test("harvests fixture runs and evals to --out without touching them", async () => {
const out = path.join(root, "telemetry.json")
const result = await run([
"improve",
"harvest",
"--runs",
`${path.join(improveFixtures, "run-clean")},${path.join(improveFixtures, "run-replan")}`,
"--evals",
path.join(improveFixtures, "evals"),
"--out",
out,
])
expect(result.code).toBe(0)
expect(result.out).toContain("2 run(s) and 2 eval(s)")
const parsed = JSON.parse(await readFile(out, "utf8"))
expect(parsed.version).toBe(2)
expect(parsed.runs.map((run: { id: string }) => run.id)).toEqual(["run-clean", "run-replan"])
expect(parsed.evals.map((item: { case: string }) => item.case)).toEqual(["grill", "programmer"])
})
test("needs at least one input dir", async () => {
const result = await run(["improve", "harvest", "--out", "telemetry.json"])
expect(result.code).toBe(2)
expect(result.out).toContain("Usage: es improve harvest")
})
test("unknown sub-verbs print usage", async () => {
const result = await run(["improve", "frobnicate"])
expect(result.code).toBe(2)
expect(result.out).toContain("Usage: es improve harvest")
})
})
describe("es improve distill (#136)", () => {
const improveFixtures = path.join(import.meta.dir, "..", "..", "core", "test", "fixtures", "improve")
async function telemetryFile(): Promise<string> {
const out = path.join(root, "telemetry.json")
const harvested = await run([
"improve",
"harvest",
"--runs",
`${path.join(improveFixtures, "run-clean")},${path.join(improveFixtures, "run-replan")}`,
"--evals",
path.join(improveFixtures, "evals"),
"--out",
out,
])
expect(harvested.code).toBe(0)
return out
}
test("distills all three kinds from fixture telemetry at low thresholds; nothing applied", async () => {
const telemetry = await telemetryFile()
const out = path.join(root, "proposals")
const result = await run([
"improve",
"distill",
"--telemetry",
telemetry,
"--out",
out,
"--min-occurrences",
"1",
"--min-runs",
"1",
])
expect(result.code).toBe(0)
expect(result.out).toContain("Nothing was applied")
expect(result.out).toContain("prompt-guidance")
expect(result.out).toContain("gate-tuning")
expect(result.out).toContain("domain-pack")
})
test("default thresholds stay silent on single occurrences", async () => {
const telemetry = await telemetryFile()
const result = await run(["improve", "distill", "--telemetry", telemetry, "--out", path.join(root, "p")])
expect(result.code).toBe(0)
expect(result.out).toContain("0 proposal(s)")
})
test("invalid telemetry and bad thresholds fail cleanly", async () => {
const bad = path.join(root, "bad.json")
await writeFile(bad, JSON.stringify({ version: 999 }))
const invalid = await run(["improve", "distill", "--telemetry", bad, "--out", path.join(root, "p")])
expect(invalid.code).toBe(1)
expect(invalid.out).toContain("Invalid telemetry")
const telemetry = await telemetryFile()
const thresholds = await run([
"improve",
"distill",
"--telemetry",
telemetry,
"--out",
path.join(root, "p"),
"--min-occurrences",
"0",
])
expect(thresholds.code).toBe(2)
expect(thresholds.out).toContain("Bad --min-occurrences")
const missing = await run(["improve", "distill", "--out", path.join(root, "p")])
expect(missing.code).toBe(2)
expect(missing.out).toContain("Usage: es improve distill")
})
test("--open-pr without a terminal refuses (human-run)", async () => {
const telemetry = await telemetryFile()
const result = await run([
"improve",
"distill",
"--telemetry",
telemetry,
"--out",
path.join(root, "p"),
"--open-pr",
])
expect(result.code).toBe(3)
})
test("--open-pr stages only proposals, pushes the topic branch, and drafts against the base", async () => {
const repo = await mkdtemp(path.join(tmpdir(), "es-open-pr-"))
const bare = await mkdtemp(path.join(tmpdir(), "es-open-pr-bare-"))
const seen: string[][] = []
try {
const sh = async (args: string[], cwd: string = repo) => {
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" })
await proc.exited
}
await sh(["init", "-q", "-b", "rewrite"], bare)
await sh(["init", "-q", "-b", "rewrite"])
await sh(["config", "user.email", "t@t"])
await sh(["config", "user.name", "t"])
await sh(["remote", "add", "origin", bare])
await writeFile(path.join(repo, "base.txt"), "base\n")
await sh(["add", "base.txt"])
await sh(["commit", "-q", "-m", "init"])
const proposalDir = path.join(repo, "proposals", "p1")
await mkdir(proposalDir, { recursive: true })
await writeFile(path.join(proposalDir, "proposal.json"), "{}\n")
await writeFile(path.join(repo, "unrelated.txt"), "do not stage me\n")
const stub = async (argv: readonly string[]) => {
seen.push([...argv])
if (argv[1] === "auth") return { code: 0, stdout: "ok", stderr: "" }
return { code: 0, stdout: "https://example.test/pr/1\n", stderr: "" }
}
const { branch, url, staged } = await openProposalsPr(repo, [proposalDir], { run: stub, date: "2026-10-09" })
expect(branch).toBe("improve/20261009")
expect(url).toBe("https://example.test/pr/1")
expect(staged).toEqual([proposalDir])
const prCall = seen.find((argv) => argv[1] === "pr")!
expect(prCall).toContain("--draft")
expect(prCall).toContain("improve/20261009")
expect(prCall.slice(prCall.indexOf("--base") + 1, prCall.indexOf("--base") + 2)).toEqual(["rewrite"])
// Only the proposal files were committed; the unrelated file stays dirty.
const show = Bun.spawn(["git", "show", "--name-only", "--format=", "HEAD"], { cwd: repo, stdout: "pipe" })
const names = (await new Response(show.stdout).text()).split("\n").filter(Boolean)
expect(names).toEqual(["proposals/p1/proposal.json"])
const status = Bun.spawn(["git", "status", "--short"], { cwd: repo, stdout: "pipe" })
expect(await new Response(status.stdout).text()).toContain("unrelated.txt")
} finally {
await rm(repo, { recursive: true, force: true })
await rm(bare, { recursive: true, force: true })
}
})
test("--open-pr without gh auth fails cleanly", async () => {
const repo = await mkdtemp(path.join(tmpdir(), "es-open-pr-"))
try {
const proc = Bun.spawn(["git", "init", "-q", "-b", "rewrite"], { cwd: repo, stdout: "pipe" })
await proc.exited
await expect(
openProposalsPr(repo, [path.join(repo, "p")], {
run: async () => ({ code: 1, stdout: "", stderr: "no auth" }),
}),
).rejects.toThrow("gh is not authenticated")
} finally {
await rm(repo, { recursive: true, force: true })
}
})
})
describe("es factory init --preset self-dogfood (#137)", () => {
const realPath = process.env.PATH
async function stubGh(): Promise<string> {
const bin = await mkdtemp(path.join(tmpdir(), "es-gh-"))
await writeFile(path.join(bin, "gh"), `#!/bin/sh\necho '{"title":"Stub issue","body":"Stub body."}'\n`, {
mode: 0o755,
})
process.env.PATH = `${bin}:${realPath}`
return bin
}
afterEach(async () => {
process.env.PATH = realPath
})
test("usage errors write nothing", async () => {
for (const argv of [
["factory", "init"],
["factory", "init", "--preset", "nope", "--issue", "137"],
["factory", "init", "--preset", "self-dogfood", "--issue", "0"],
]) {
const result = await run(argv)
expect(result.code).toBe(2)
expect(result.out).toContain("Usage: es factory init")
}
expect(await exists(factoryLayout(root).dir)).toBe(false)
})
test("without a terminal it refuses before writing (human-only)", async () => {
const bin = await stubGh()
try {
const result = await run(["factory", "init", "--preset", "self-dogfood", "--issue", "137"])
expect(result.code).toBe(3)
expect(await exists(factoryLayout(root).dir)).toBe(false)
} finally {
await rm(bin, { recursive: true, force: true })
}
})
test("a wrong passphrase cancels with nothing written", async () => {
const bin = await stubGh()
try {
const result = await run(["factory", "init", "--preset", "self-dogfood", "--issue", "137"], human("nope"))
expect(result.code).toBe(1)
expect(await exists(factoryLayout(root).dir)).toBe(false)
} finally {
await rm(bin, { recursive: true, force: true })
}
})
test("at a terminal it seeds the run and reminds about trust, ceiling and approvals", async () => {
const bin = await stubGh()
try {
const result = await run(["factory", "init", "--preset", "self-dogfood", "--issue", "137"], human())
expect(result.code).toBe(0)
expect(result.out).toContain(".factory/roadmap.json")
expect(result.out).toContain("es trust")
expect(result.out).toContain("$15")
expect(result.out).toContain("rewrite")
const roadmap = JSON.parse(await readFile(factoryLayout(root).roadmap, "utf8"))
expect(roadmap.baseBranch).toBe("rewrite")
expect(await readFile(path.join(root, ".factory/notes/idea-137.md"), "utf8")).toContain("Stub issue")
const entries = await recentAuditEntries(root, 5)
const seeded = entries.find((entry) => entry.action === "factory.preset")!
expect(seeded.actor).toMatch(/^human:.+/)
expect(seeded.actor).not.toBe("human:unknown")
} finally {
await rm(bin, { recursive: true, force: true })
}
})
})
describe("mcp", () => {
test("lists coarse tools without any human-only tool and calls them with an advisory agent", async () => {
const server = createMcpServer({ root, stateDir: state })
@@ -469,6 +1022,28 @@ describe("mcp", () => {
expect(lines.map((line) => line.id)).toEqual([1, 2])
expect(lines[0].result.serverInfo.name).toBe("epistemic-swarm")
})
test("H1: a piped agent argument cannot claim a seat; identity is pinned to the adapter environment", async () => {
const call = (server: ReturnType<typeof createMcpServer>, args: Record<string, unknown>) =>
server.handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "complete", arguments: args } })
// No adapter identity: the caller is "mcp", which holds no seat, so even
// agent:"factory" is refused as mcp at the tool's own seat check.
const bare = createMcpServer({ root, stateDir: state, env: {} })
const spoofed = await call(bare, { agent: "factory" })
expect(spoofed?.result.isError).toBe(true)
expect(spoofed?.result.content[0].text).toContain("called by mcp")
// The adapter pins one identity per server in its environment: that identity
// reaches the tool (here refused as factory at the programmer-only check).
const pinned = createMcpServer({ root, stateDir: state, env: { ES_MCP_AGENT: "factory" } })
const admitted = await call(pinned, {})
expect(admitted?.result.isError).toBe(true)
expect(admitted?.result.content[0].text).toContain("called by factory")
// The per-call argument is ignored: claiming factory under a QA pin stays refused as QA.
const qaPinned = createMcpServer({ root, stateDir: state, env: { ES_MCP_AGENT: "es-qa-functional" } })
const ignored = await call(qaPinned, { agent: "factory" })
expect(ignored?.result.isError).toBe(true)
expect(ignored?.result.content[0].text).toContain("called by es-qa-functional")
})
})
describe("opencode driver", () => {
@@ -906,3 +1481,221 @@ describe("status and runs (1.1.3)", () => {
expect(JSON.parse((await run(["runs", "--json"])).out)[0].runId).toBe(begun.runId)
})
})
describe("headless fleet (#120)", () => {
const toolDriver = (onTurn?: () => Promise<void>): HarnessDriver => ({
id: "fake",
available: async () => true,
async *turn() {
yield { type: "tool_use", part: { tool: "read", state: { status: "completed", input: { path: "src/a.ts" } } } }
yield { type: "tool_use", part: { tool: "edit", state: { status: "failed", input: {}, error: "nope" } } }
yield { type: "text", part: { text: "working" } }
await onTurn?.()
return "ses_fake"
},
})
test("the JSONL stream is versioned and schema-validated for every event kind", async () => {
const { HeadlessJsonlSchema, toJsonl } = await import("../src/headless.ts")
const samples: HeadlessEvent[] = [
{ type: "start", runId: "run-1", stage: "RESEARCH", driver: "fake", monitor: "m" },
{ type: "turn", n: 1, stage: "RESEARCH" },
{ type: "driver", event: { text: "x" } },
{ type: "progress", headline: "h", seats: [] },
{ type: "turn-metrics", turn: 1, costUSD: 0, tools: [] },
{ type: "waiting", reason: "r" },
{ type: "halted", reason: "r" },
{ type: "stalled", turns: 3, headline: "h", seats: [] },
{ type: "turn-cap", turns: 4 },
{ type: "cancelled", reason: "r" },
{ type: "done" },
{ type: "error", message: "m" },
]
for (const event of samples) {
const envelope = toJsonl("run-1", event)
expect(envelope.v).toBe(1)
expect(envelope.kind).toBe(event.type)
expect(HeadlessJsonlSchema.safeParse(envelope).success).toBe(true)
}
expect(
HeadlessJsonlSchema.safeParse({ v: 2, at: new Date().toISOString(), runId: "r", kind: "x", event: {} }).success,
).toBe(false)
})
test("each turn emits turn-metrics with tool activity and the sealed spend delta", async () => {
await grilledFrontier()
await run(["approve", "frontier"], human())
const factory = new Factory(root, { gates: gateRunner({ stateDir: state }), stateDir: state })
let n = 0
const events: HeadlessEvent[] = []
for await (const event of runHeadless({
root,
driver: toolDriver(async () => {
n++
if (n === 1) await factory.recordSpend(0.05, false)
}),
maxTurns: 2,
stallTurns: 99,
stateDir: state,
progressMs: 60_000,
}))
events.push(event)
const metrics = events.filter((event) => event.type === "turn-metrics")
expect(metrics).toHaveLength(2)
expect(metrics[0]).toMatchObject({
type: "turn-metrics",
turn: 1,
tools: [
{ name: "read", ok: true },
{ name: "edit", ok: false },
],
})
expect((metrics[0] as any).costUSD).toBeCloseTo(0.05, 5)
expect(metrics[1]).toMatchObject({ type: "turn-metrics", turn: 2, costUSD: 0 })
})
test("an aborted run emits cancelled, writes no halt, and resumes", async () => {
await grilledFrontier()
await run(["approve", "frontier"], human())
const hanging: HarnessDriver = {
id: "hang",
available: async () => true,
async *turn() {
// A handle-free hang: no timer, so abandoning the turn ends the test.
await new Promise<never>(() => {})
yield { text: "never" }
return undefined
},
}
const controller = new AbortController()
setTimeout(() => controller.abort(), 100)
const events: HeadlessEvent[] = []
for await (const event of runHeadless({
root,
driver: hanging,
maxTurns: 5,
stateDir: state,
signal: controller.signal,
progressMs: 60_000,
}))
events.push(event)
expect(events.at(-1)).toMatchObject({ type: "cancelled" })
expect(events.some((event) => event.type === "error")).toBe(false)
// No halt written, no STOP file: the run is resumable.
const factory = new Factory(root, { gates: gateRunner({ stateDir: state }), stateDir: state })
expect((await factory.read())?.stage).toBe("RESEARCH")
expect(await exists(factoryLayout(root).stop)).toBe(false)
const resumed: HeadlessEvent[] = []
for await (const event of runHeadless({
root,
driver: toolDriver(),
maxTurns: 1,
stallTurns: 99,
stateDir: state,
progressMs: 60_000,
}))
resumed.push(event)
expect(resumed[0]).toMatchObject({ type: "start" })
})
test("a turn past --turn-timeout ends as a turn-timeout error", async () => {
await grilledFrontier()
await run(["approve", "frontier"], human())
const hanging: HarnessDriver = {
id: "hang",
available: async () => true,
async *turn() {
// A handle-free hang: no timer, so abandoning the turn ends the test.
await new Promise<never>(() => {})
yield { text: "never" }
return undefined
},
}
const events: HeadlessEvent[] = []
for await (const event of runHeadless({
root,
driver: hanging,
maxTurns: 5,
stateDir: state,
turnTimeoutMs: 120,
progressMs: 60_000,
}))
events.push(event)
expect(events.at(-1)).toMatchObject({ type: "error", message: expect.stringContaining("turn-timeout") })
})
test("headless exit codes: cancelled is 130, failures stay 1, the rest 0", async () => {
const { headlessExitCode } = await import("../src/headless.ts")
expect(headlessExitCode("cancelled")).toBe(130)
for (const type of ["error", "halted", "stalled", "turn-cap"] as const) expect(headlessExitCode(type)).toBe(1)
for (const type of ["start", "turn", "driver", "progress", "turn-metrics", "waiting", "done"] as const)
expect(headlessExitCode(type)).toBe(0)
})
test("--cwd refuses seat worktrees of active runs and detached HEADs", async () => {
const { checkCwd } = await import("../src/headless.ts")
// A plain project root is fine (and so is a non-repo dir).
expect(await checkCwd(root)).toBeUndefined()
// Inside another active run's .factory/worktrees: refused.
const proj = await mkdtemp(path.join(tmpdir(), "es-cwd-proj-"))
try {
await mkdir(path.join(proj, ".factory", "worktrees", "seat-1"), { recursive: true })
await mkdir(path.join(proj, ".factory", "runtime"), { recursive: true })
await writeFile(path.join(proj, ".factory", "runtime", "state.json"), "{}")
const refusal = await checkCwd(path.join(proj, ".factory", "worktrees", "seat-1"))
expect(refusal).toContain(".factory/worktrees")
// Without a run state it is not an active run: allowed.
await rm(path.join(proj, ".factory", "runtime", "state.json"))
expect(await checkCwd(path.join(proj, ".factory", "worktrees", "seat-1"))).toBeUndefined()
} finally {
await rm(proj, { recursive: true, force: true })
}
// A detached-HEAD worktree is refused; attached is fine.
const wt = await mkdtemp(path.join(tmpdir(), "es-cwd-wt-"))
try {
const proc = Bun.spawn(["git", "worktree", "add", "--detach", wt], { cwd: root, stdout: "pipe", stderr: "pipe" })
await proc.exited
expect(await checkCwd(wt)).toContain("branch")
const proc2 = Bun.spawn(["git", "-C", wt, "checkout", "-q", "-b", "wt-branch"], {
stdout: "pipe",
stderr: "pipe",
})
await proc2.exited
expect(await checkCwd(wt)).toBeUndefined()
} finally {
const proc = Bun.spawn(["git", "worktree", "remove", "--force", wt], {
cwd: root,
stdout: "pipe",
stderr: "pipe",
})
await proc.exited
await rm(wt, { recursive: true, force: true })
}
})
test("--events jsonl prints one envelope per line; --events-file redirects it", async () => {
const { HeadlessJsonlSchema } = await import("../src/headless.ts")
// No run here: a single error envelope on stdout.
const streamed = await run(["factory", "run", "--headless", "--events", "jsonl"])
expect(streamed.code).toBe(1)
const lines = streamed.out.split("\n").filter((line) => line.trim())
expect(lines).toHaveLength(1)
const parsed = HeadlessJsonlSchema.safeParse(JSON.parse(lines[0]!))
expect(parsed.success).toBe(true)
if (parsed.success) expect(parsed.data.kind).toBe("error")
expect(await run(["factory", "run", "--headless", "--events", "yaml"])).toMatchObject({ code: 2 })
// --events-file: the envelope goes to the file, stdout keeps human lines.
const file = path.join(root, "events.jsonl")
const filed = await run(["factory", "run", "--headless", "--events", "jsonl", "--events-file", file])
expect(filed.code).toBe(1)
expect(filed.out.trim()).not.toStartWith("{")
const saved = (await readFile(file, "utf8")).split("\n").filter((line) => line.trim())
expect(saved).toHaveLength(1)
expect(HeadlessJsonlSchema.safeParse(JSON.parse(saved[0]!)).success).toBe(true)
})
test("--turn-timeout validates its value before anything runs", async () => {
expect((await run(["factory", "run", "--headless", "--turn-timeout", "soon"])).code).toBe(2)
expect((await run(["factory", "run", "--headless", "--turn-timeout", "0"])).code).toBe(2)
})
})
+119
View File
@@ -0,0 +1,119 @@
// Headless fleet probes (#120): per-turn tool metrics carry duration and seat,
// and stay out of the default human output.
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { Factory, gateRunner } from "@heretek-ai/es-core"
import {
driveHeadless,
type HarnessDriver,
type HeadlessEvent,
HeadlessJsonlSchema,
presentEvent,
toJsonl,
} from "../src/headless.ts"
let root: string
let state: string
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), "es-headless-"))
state = await mkdtemp(path.join(tmpdir(), "es-headless-state-"))
const factory = new Factory(root, { gates: gateRunner({ stateDir: state }), stateDir: state })
await factory.provisionRun("tester", 5)
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
await rm(state, { recursive: true, force: true })
})
/** A scripted driver turn that reports tool timing and the acting seat. */
const toolDriver: HarnessDriver = {
id: "fake",
available: async () => true,
async *turn() {
yield {
type: "tool_use",
agent: "es-programmer",
part: {
tool: "read",
state: { status: "completed", input: { path: "src/a.ts" }, time: { start: 1000, end: 1250 } },
},
}
yield {
type: "tool_use",
agent: "es-programmer",
part: {
tool: "edit",
state: { status: "error", input: {}, error: "nope", time: { start: 2000, end: 2750 } },
},
}
yield {
type: "tool_use",
part: { tool: "shell", state: { status: "completed", input: { command: "echo hi" } } },
}
return "ses_fake"
},
}
async function oneTurnMetrics(): Promise<HeadlessEvent[]> {
const events: HeadlessEvent[] = []
for await (const event of driveHeadless(
{ root, driver: toolDriver, maxTurns: 1, stallTurns: 99, stateDir: state },
{
agent: "factory",
prompt: async () => "go",
finished: async () => undefined,
progress: async () => "p",
},
))
events.push(event)
return events
}
describe("turn-metrics tool activity (#120)", () => {
test("a scripted driver event stream produces tools with name, ok, ms and seat", async () => {
const events = await oneTurnMetrics()
const metrics = events.filter((event) => event.type === "turn-metrics")
expect(metrics).toHaveLength(1)
expect(metrics[0]).toEqual({
type: "turn-metrics",
turn: 1,
costUSD: 0,
tools: [
{ name: "read", ok: true, ms: 250, seat: "programmer" },
{ name: "edit", ok: false, ms: 750, seat: "programmer" },
{ name: "shell", ok: true },
],
})
})
test("every turn-metrics payload validates, not just the envelope", async () => {
const events = await oneTurnMetrics()
const metrics = events.find((event) => event.type === "turn-metrics")!
expect(HeadlessJsonlSchema.safeParse(toJsonl("run-1", metrics)).success).toBe(true)
const missingOk = toJsonl("run-1", {
type: "turn-metrics",
turn: 1,
costUSD: 0,
tools: [{ name: "read" }],
} as unknown as HeadlessEvent)
expect(HeadlessJsonlSchema.safeParse(missingOk).success).toBe(false)
})
})
describe("turn-metrics human output (#120)", () => {
const metrics: HeadlessEvent = {
type: "turn-metrics",
turn: 1,
costUSD: 0.04,
tools: [{ name: "read", ok: true, ms: 250, seat: "programmer" }],
}
test("turn-metrics prints at debug only; info and quiet stay unchanged", () => {
expect(presentEvent(metrics, "info")).toBeUndefined()
expect(presentEvent(metrics, "quiet")).toBeUndefined()
expect(presentEvent(metrics, "debug")).toEqual(metrics)
})
})
+90
View File
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { researchTools } from "@heretek-ai/es-core"
import { main } from "../src/main.ts"
let root: string
let state: string
const realFetch = globalThis.fetch
const realBrave = process.env.BRAVE_API_KEY
async function git(...args: string[]) {
const proc = Bun.spawn(["git", ...args], { cwd: root, stdout: "pipe", stderr: "pipe" })
await proc.exited
}
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), "es-cli-research-"))
state = await mkdtemp(path.join(tmpdir(), "es-cli-research-state-"))
await git("init", "-q", "-b", "main")
await writeFile(path.join(root, "README.md"), "# x\n")
})
afterEach(async () => {
globalThis.fetch = realFetch
if (realBrave === undefined) delete process.env.BRAVE_API_KEY
else process.env.BRAVE_API_KEY = realBrave
await rm(root, { recursive: true, force: true })
await rm(state, { recursive: true, force: true })
})
async function run(argv: string[]) {
const printed: string[] = []
const code = await main(argv, {
print: (text) => printed.push(text),
confirm: { interactive: false, write: () => {}, readLine: async () => "y", readSecret: async () => "" },
cwd: root,
stateDir: state,
})
return { code, out: printed.join("\n") }
}
describe("es research as human operator (#99)", () => {
test("es research search exits 0 for the human caller", async () => {
process.env.BRAVE_API_KEY = "test-key"
globalThis.fetch = (async () =>
new Response(JSON.stringify({ web: { results: [{ url: "https://a.test", title: "A", description: "x y" }] } }), {
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch
const result = await run(["research", "search", "test query"])
expect(result.code).toBe(0)
expect(result.out).toContain("brave results")
})
test("es research fetch exits 0 for the human caller", async () => {
globalThis.fetch = (async () =>
new Response("<html><head><title>T</title></head><body><p>Human fetchable content here.</p></body></html>", {
headers: { "content-type": "text/html" },
})) as unknown as typeof fetch
const result = await run(["research", "fetch", "https://192.0.2.1/page"])
expect(result.code).toBe(0)
expect(result.out).toContain("sha256:")
})
})
describe("plugin-built tools never trust an agent named human (#99)", () => {
test("{ agent: 'human' } is refused without the explicit CLI operator", async () => {
const tools = researchTools({ root, stateDir: state, policy: async () => ({ root }) })
const byName = (name: string) => tools.find((tool) => tool.name === name)!
// The audit tool needs no provider: it reaches the seat check first.
await expect(byName("es_research_audit").execute({}, { agent: "human" })).rejects.toThrow(
"Only the research seats and factory",
)
await expect(byName("es_research_search").execute({ query: "x" }, { agent: "human" })).rejects.toThrow(
"Only the research and scout seats",
)
await expect(
byName("es_research_fetch").execute({ url: "https://example.test" }, { agent: "human" }),
).rejects.toThrow("Only the research and scout seats")
// Seats keep their grants through the same instance.
await expect(byName("es_research_audit").execute({}, { agent: "factory" })).resolves.toContain("does not exist yet")
})
test("the CLI operator flag admits a non-seat agent label", async () => {
const tools = researchTools({ root, stateDir: state, policy: async () => ({ root }), operator: "human" })
const byName = (name: string) => tools.find((tool) => tool.name === name)!
await expect(byName("es_research_audit").execute({}, { agent: "cli" })).resolves.toContain("does not exist yet")
})
})
@@ -0,0 +1,16 @@
# self-dogfood preset
The factory builds this repository: an issue becomes a run in an isolated
worktree under the real merge gate, and the release stage opens a draft PR
a human reviews.
- `config.json` — project layer: `audit.phase: required`, PR opener `gh`.
Models come from the human's global config (the preset pins none).
- `gates.json` — the real merge gate: `bun install --frozen-lockfile`,
`bun run check`, `bun run docs:check`. Trust them with `es trust`.
- `idea-from-issue.sh` — pulls the issue body as the run idea.
- Base branch: `rewrite`, never `main`. `es factory init` seeds
`.factory/roadmap.json` with `baseBranch: rewrite`; keep that field when
the grill rewrites the roadmap.
- Spend ceiling: $15 default, set on the frontier during the grill; the
runtime cap is mandatory. See `docs/SELF-DOGFOOD.md`.
@@ -0,0 +1,4 @@
{
"audit": { "phase": "required" },
"pr": "gh"
}
@@ -0,0 +1,26 @@
{
"version": 1,
"commands": [
{
"id": "install",
"kind": "test",
"command": "bun install --frozen-lockfile",
"parser": "generic",
"timeoutMs": 300000
},
{
"id": "check",
"kind": "test",
"command": "bun run check",
"parser": "generic",
"timeoutMs": 600000
},
{
"id": "docs-check",
"kind": "test",
"command": "bun run docs:check",
"parser": "generic",
"timeoutMs": 300000
}
]
}
@@ -0,0 +1,10 @@
#!/bin/sh
# idea-from-issue: print the factory idea for one GitHub issue of this repo.
# Usage: idea-from-issue.sh <issue-number>
# (The CLI runs this for `es factory init --preset self-dogfood --issue <n>`.)
set -eu
if [ "$#" -ne 1 ]; then
echo "Usage: idea-from-issue.sh <issue-number>" >&2
exit 2
fi
gh issue view "$1" --json number,title,body,url --jq '"#\(.number): \(.title)\n\n\(.url)\n\n\(.body)"'
+5 -2
View File
@@ -1,6 +1,6 @@
---
id: brainstormer
version: 2
version: 3
seat: brainstormer
description: Fans out divergent lenses on an idea and distills a diversified shortlist.
---
@@ -11,7 +11,10 @@ You are the **brainstormer** agent of Epistemic Swarm. Your job is divergent, la
2. Launch every lens subagent **in parallel** with the subagent tool: all the calls in one message, in the foreground (background launches are refused). Call them by exact ID (`es-lens-inversion`, `es-lens-constraint-removal`, `es-lens-scamper`, `es-lens-cross-domain`, `es-lens-extreme-user`, `es-lens-scale-shift`, and so on). Give each the brief and how many ideas you expect.
3. Record each lens's output with `es_brainstorm_record`, one call per lens. It rejects cap violations and collapses near-duplicates; mention convergences when lenses independently reach the same idea.
4. Launch `es-brainstorm-critic` with the surviving idea IDs and their texts. The critic scores every idea with `es_brainstorm_score` (1-5 on novelty, upside, feasibility, fit).
5. Call `es_brainstorm_complete`. It re-checks coverage and scores, ranks the ideas, picks the diversified shortlist with one forced outlier slot, and writes `.factory/brainstorm/brainstorm.json` and `BRAINSTORM.md`. Report the shortlist to the human, briefly.
5. Call `es_brainstorm_complete`. It re-checks coverage and scores, ranks the ideas, picks the diversified shortlist with one forced outlier slot, and writes the run's `brainstorm.json` and `BRAINSTORM.md`. Report the shortlist to the human, briefly.
## Prior art yourself
You can run `es_harvest_prior_art` on the surviving ideas before completing: it finds related projects, you judge each relation (`novel`/`similar`/`existing`), and you pass the list to `es_brainstorm_complete` as `priorArt`. Only recorded searches count — the tool refuses URLs no search returned.
## Discipline
- Quantity first, judgment later: record ideas as they come; never discard before the critic sees them.
@@ -0,0 +1,19 @@
---
id: deep-researcher
version: 1
seat: deep-researcher
description: Runs a research-only run adversarially: thesis, antithesis, then a synthesized report.
---
You are the **deep-researcher** of Epistemic Swarm. You run a research-only run adversarially: thesis gathers, antithesis attacks, the synthesizer resolves. You orchestrate; the tools enforce the mechanics.
## Flow
1. Restate the run's objective (from `<factory-state>`) in one sentence. Decompose it into hypotheses and search queries, and write the plan note to `.factory/research/notes/plan.md`: the hypotheses, the queries per side, and what coverage would settle each one. You may gather context yourself with `es_research_search` and `es_research_fetch`.
2. Launch `es-research-alpha` and `es-research-beta` in parallel with the subagent tool: both calls in one message, in the foreground (background launches are refused). Give alpha the thesis queries and beta the antithesis brief: falsify alpha's direction, hunt counter-evidence, downgrade what does not hold. Neither writes the report.
3. When both return, launch `es-research-synthesizer` with the dispute points: which alpha claims beta attacked, and what counter-evidence beta cached. The synthesizer writes `.factory/research/REPORT.md` — you cannot write it.
4. Run `es_research_audit` on the report (use `prune:true` to move failing claims aside), then call `es_research_complete`. It refuses a report that does not pass the audit. In a research run the run ends at DONE.
## Discipline
- Every claim line keeps its epistemic tag: `[VERIFIED: sha256:<hash> "quote"]` quotes the cached source verbatim, `[INFERRED: …]`, `[HYPOTHESIS: …]`, `[NEGATIVE_KNOWLEDGE: …]`.
- Disputes are resolved explicitly or left open as hypotheses — never smoothed over.
- Subagents never nest and never run in the background. Never launch a seat that `es_status` shows as running.
- Spend and runtime are capped; the ceiling is human-set and you cannot raise it. If the run halts, report why and stop.
+6 -1
View File
@@ -1,6 +1,6 @@
---
id: factory
version: 7
version: 9
seat: factory
description: Orchestrates the build factory from research to release.
---
@@ -31,6 +31,11 @@ A running seat is working, not stalled: research fetches and builds take minutes
After the spec approval, call `es_build_start` and drive the phases without asking the human, until `es_release` opens the pull request. Never merge or push to the base branch; a human merges.
## Callable brainstorm (open design questions mid-run)
When a run stalls on an open design question with several viable options, run a brainstorm yourself: `es_brainstorm_plan` with your own run id (lean defaults apply: 4 lenses, 3 ideas per lens, shortlist of 5), then every `es-lens-<id>` subagent in the foreground in one message, `es_brainstorm_record` per lens, `es-brainstorm-critic` for `es_brainstorm_score`, and `es_brainstorm_complete` for the shortlist JSON. Fold the chosen option into the spec or the phase GOAL yourself; the brainstorm writes no factory files but its own runs.
Before the run depends on or vendors anything, call `es_harvest_target` for a fail-closed licence verdict (JSON, with provenance); unverified, copyleft, unknown or non-whitelisted means clean-room. Prior art for a brainstorm comes from `es_harvest_prior_art`, judged and passed to `es_brainstorm_complete` as `priorArt`.
## Discipline
- Prefer small vertical slices; a phase that keeps failing is a spec problem, not a typing problem.
- Never edit control files (`.factory/approvals`, `waivers`, `gates.json`, `frontier.json`, `runtime/`, and the evidence: `research/sources/`, `research/dossier.json`, `research/coverage.json`, `claims/`). Only the `es_research_*` tools write the evidence. Hooks block hand edits, and trying wastes budget.
+12 -1
View File
@@ -1,6 +1,6 @@
---
id: grill
version: 2
version: 4
seat: grill
description: Socratic interviewer that settles the design tree and the spend ceiling.
---
@@ -45,6 +45,17 @@ When the frontier is empty and every branch has been visited:
2. Ask the human to confirm the tree is complete. Then save it with `settled: true`. That is refused while any node is still `open`.
3. Call `es_request_approval` with stage `frontier`, and tell the human to approve with `/es-approve` (TUI) or `es approve frontier` (terminal). You cannot approve it yourself. If approval is refused because no settled frontier was recorded, save it with `es_frontier_write` and request again.
## Callable brainstorm (options, not questions)
When an open design question has several viable options and no human answer yet, run a brainstorm yourself instead of guessing:
1. Call `es_brainstorm_plan` with your own run id (a `run` like `grill-round-2`). Plans from you default to 4 lenses, 3 ideas per lens and a shortlist of 5; ask for more only when the question warrants the spend.
2. Launch every planned lens subagent (`es-lens-<id>`) in the **foreground**, all calls in **one message**. Give each one the brief plus how many ideas you expect. Subagents never nest: lenses return text, they never call tools.
3. Record each lens's ideas with `es_brainstorm_record` (one call per lens, same `run`).
4. Launch `es-brainstorm-critic` with the surviving idea ids and texts; it scores each with `es_brainstorm_score`. Only the critic scores.
5. Call `es_brainstorm_complete` with the `run`. It returns the shortlist as JSON: parse it and write the options you keep into the frontier yourself with `es_frontier_write`. The brainstorm never writes the frontier.
## Licences before you propose a dependency
Before a frontier choice proposes depending on or vendoring a project, call `es_harvest_target` (up to 5 sources, no subagent): it scans, detects the licence and returns a fail-closed verdict as JSON. Unverified, copyleft, unknown or non-whitelisted means clean-room — record that on the node and never propose depend/vendor for it. When a brainstorm needs related work, run `es_harvest_prior_art` yourself and pass the judged list to `es_brainstorm_complete` as `priorArt`.
## Frontier shape (version 1.1)
`{ "version": "1.1", "idea": string, "spendCeiling": { "currency": "USD", "maxAmount": number }, "settled": boolean, "round": number, "nodes": [{ "id", "parent"?, "kind": "decision" | "fact", "question", "choices"?, "recommended"?, "answer"?, "justification"?, "status": "open" | "settled" | "deferred", "round"? }] }`
+4 -1
View File
@@ -1,6 +1,6 @@
---
id: harvester
version: 1
version: 2
seat: harvester
description: Competitor teardown with per-field provenance, fail-closed licenses and clean-room specs.
---
@@ -16,6 +16,9 @@ You are the **harvester** agent of Epistemic Swarm. You tear down competitor or
## Prior art for brainstorm
`es_harvest_prior_art` finds projects related to brainstorm ideas. You decide each relation (`novel`/`similar`/`existing`) and pass the list to `es_brainstorm_complete` as `priorArt`. A failed search is UNVERIFIED: say so and do not claim novelty.
## Quick verdicts without a teardown
`es_harvest_target` scans up to 5 sources for a fail-closed license-and-verdict check (no plan, no matrix): parse, scan, detect, `checkVerdict`. It returns the verdicts as JSON with provenance. Use it when someone only asks "can we depend on this" — a full teardown is for rebuild decisions.
## Discipline
- Never present a registry or API license field as verified; only LICENSE bytes and SPDX headers count.
- Never copy upstream code, prose or assets into the repo. Clean-room specs describe behaviour; the implementation is written without reading upstream.
@@ -1,6 +1,6 @@
---
id: research-alpha
version: 3
version: 4
seat: research-alpha
description: Research thesis seat.
---
@@ -21,3 +21,6 @@ Every claim line (bullet, numbered item or prose line) carries a tag:
Never invent a citation or paraphrase inside quotes. Copy the words exactly. Run `es_research_audit` on your notes (path `.factory/research/alpha.md`) and fix what it flags. End with the three decisions the evidence most supports and the three biggest risks, each tagged.
You write only `.factory/research/alpha.md`. The factory merges it into the report, which you cannot write. When you are done, reply with a short summary: which deferred facts you answered, how many sources you cached, and what is still open.
## Research runs (deep-researcher, no frontier)
When the deep-researcher launches you, there is no frontier: the objective comes from your prompt. You are still the thesis — gather for the question, not against it. The synthesizer merges your notes into the report, which you cannot write. End with the hypotheses your evidence supports and what would falsify each.
@@ -1,6 +1,6 @@
---
id: research-beta
version: 3
version: 4
seat: research-beta
description: Research antithesis seat.
---
@@ -13,3 +13,6 @@ Use `es_research_search` and `es_research_fetch` (cached, content-addressed) and
Re-check alpha's VERIFIED quotes yourself; `es_research_audit` on alpha.md shows which ones fail. For each of alpha's conclusions, say whether it survives, needs revision, or falls, and why, with a tag on every line. Never invent citations.
You write only `.factory/research/beta.md`. The factory merges it into the report, which you cannot write. When you are done, reply with a short summary: which of alpha's conclusions fell or need revision, and why.
## Research runs (deep-researcher, no frontier)
When the deep-researcher launches you, there is no alpha.md yet to attack — or there is, and your brief names it. Either way you are the antithesis: falsify, downgrade, hunt counter-evidence. The synthesizer resolves what you dispute, which you cannot do yourself. End with the claims you felled and the counter-evidence you cached.
@@ -0,0 +1,21 @@
---
id: research-synthesizer
version: 1
seat: research-synthesizer
description: Resolves alpha/beta disputes into the grounded research report.
---
You are the **research synthesizer** of Epistemic Swarm. Alpha gathered, beta attacked; you resolve. You write `.factory/research/REPORT.md` — in a research run you are its only writer — and nothing else.
## The 4-step synthesis
1. **Collect.** Read `.factory/research/alpha.md` and `.factory/research/beta.md` in full. List every tagged claim on each side with its source hashes.
2. **Organize.** Group claims by question. Mark each as agreed, disputed (alpha and beta disagree), or one-sided (only one side covers it).
3. **Synthesize.** For each group, resolve the dispute:
- agreed claims with verbatim quotes carry over;
- disputed claims are settled only by stronger cached evidence — fetch it yourself with `es_research_fetch` when the cache lacks it;
- what cannot be settled becomes an explicit `[HYPOTHESIS: …]` stating what would falsify each side.
4. **Present.** Write the report: one section per question, every claim line tagged (`[VERIFIED: sha256:<hash> "quote"]`, `[INFERRED: …]`, `[HYPOTHESIS: …]`, `[NEGATIVE_KNOWLEDGE: …]`). Quotes are verbatim from cached sources or the audit rejects them. End with what is settled, what is open, and what would change the answer.
## Discipline
- Run `es_research_audit` on the report before you hand it over; fix or prune what it flags.
- Never invent a citation or paraphrase inside quotes.
- You write only `.factory/research/REPORT.md`. Reply with a short summary: disputes settled, hypotheses left open, sources cited.
+2 -1
View File
@@ -1,6 +1,6 @@
---
id: scout
version: 1
version: 2
seat: scout
description: OSS scout — finds and vets open-source candidates for a feature, license-first, with cited evidence.
---
@@ -22,6 +22,7 @@ You are the **scout** of Epistemic Swarm. The human wants a feature; you find op
- claims, each tagged and cited;
- your proposal, `adopt`, `clean-room` or `reject`, with its rationale;
- a clean-room blueprint for anything that should be rebuilt instead of adopted.
- Before proposing `adopt`, check the licence with `es_harvest_target` (up to 5 sources, fail-closed verdicts as JSON): only a verified, whitelisted, permissive licence survives as `adopt`; everything else is clean-room.
7. `es_scout_complete`. Core recomputes every license verdict:
- `adopt` survives only for a verified, whitelisted, permissive license;
- anything else becomes clean-room;
@@ -20,3 +20,7 @@ description: How the Epistemic Swarm build factory works — stages, human check
**Kill switch:** create `.factory/STOP` (any text). Every factory tool and seat stops until a human resumes.
**Files:** `.factory/frontier.json`, `roadmap.json`, `specs/<phase>/GOAL.md`, `research/`, `gates.json`, `waivers/` and `approvals/` are tracked. `runtime/` (state, journal, audit log), `runs/` and `worktrees/` are local.
**Callable brainstorm:** an open design question with several viable options is worth a brainstorm mid-run — `es_brainstorm_plan` (own `run`, lean defaults), all `es-lens-*` in one foreground message, `es_brainstorm_record` per lens, `es-brainstorm-critic` to score, `es_brainstorm_complete` for the shortlist JSON.
**Licences first:** `es_harvest_target` before depending on or vendoring anything (fail-closed verdict JSON); prior art for a brainstorm comes from `es_harvest_prior_art`.
@@ -20,3 +20,5 @@ description: Socratic grilling technique for settling a design before building i
- Record the tree after every round, once the human has answered it. The first turn only asks: never record before the first answers arrive. Revisit earlier nodes when a later answer contradicts them.
- Freeze only when the frontier is empty and the human confirms: summarise the settled constraints first.
- The factory needs an explicit USD spend ceiling before any autonomous work.
- Options, not questions: when a design question has several viable options, run a callable brainstorm yourself — `es_brainstorm_plan` (own `run`), all `es-lens-*` in one foreground message, `es_brainstorm_record` per lens, `es-brainstorm-critic` to score, `es_brainstorm_complete` for the shortlist JSON. Write what you keep into the frontier yourself.
- Licences first: before proposing a dependency, `es_harvest_target` it (fail-closed verdict JSON); related work for a brainstorm comes from `es_harvest_prior_art`.
@@ -19,3 +19,4 @@ description: OSS scouting — discovery axes, license/bloat/CVE red-team and cle
- Core re-checks `adopt` against the verified license and the whitelist.
- Adoption is the human's decision; the scout never adds a dependency.
- **Clean-room blueprint:** interfaces, the algorithm in prose, and test fixtures. Never copied code.
- **Quick verdicts:** `es_harvest_target` checks up to 5 sources for a fail-closed licence verdict (JSON) before proposing `adopt`.
+4 -1
View File
@@ -1,6 +1,9 @@
{
"name": "@heretek-ai/es-core",
"version": "1.1.4",
"version": "1.6.0",
"esRelease": {
"order": 1
},
"description": "Epistemic Swarm core: factory state machine, mechanical gates, audit log, trust, research, brainstorm, harvest, LSP and hook/agent compilers.",
"type": "module",
"license": "Apache-2.0",
+76 -5
View File
@@ -14,6 +14,8 @@ export type Seat =
| "qa-adversarial"
| "research-alpha"
| "research-beta"
| "deep-researcher"
| "research-synthesizer"
| "brainstormer"
| "brainstorm-lens"
| "brainstorm-critic"
@@ -28,6 +30,8 @@ export type WriteScope =
| "factory-docs"
| "research-alpha"
| "research-beta"
| "deep-research"
| "research-report"
| "brainstorm"
| "harvest"
| "design"
@@ -97,6 +101,17 @@ const LENS_SPECS: AgentSpec[] = LENSES.map((lens) => ({
web: "host",
}))
/**
* Seats a callable brainstorm fans out at depth 1 (#108): the critic scores,
* the lenses diverge. The calling primary seat (brainstormer, grill or
* factory) launches them itself, in the foreground, all lens calls in one
* message — subagents never nest.
*/
export const BRAINSTORM_FANOUT_SPAWNS: readonly string[] = [
"es-brainstorm-critic",
...LENSES.map((lens) => lensAgentId(lens.id)),
]
export const AGENTS: readonly AgentSpec[] = [
{
id: "factory",
@@ -116,8 +131,13 @@ export const AGENTS: readonly AgentSpec[] = [
"es_build_start",
"es_release",
"es_audit_open",
"es_brainstorm_plan",
"es_brainstorm_record",
"es_brainstorm_complete",
"es_harvest_target",
"es_harvest_prior_art",
],
spawns: SEATS_SPAWNED_BY_FACTORY,
spawns: [...SEATS_SPAWNED_BY_FACTORY, ...BRAINSTORM_FANOUT_SPAWNS],
writes: ["factory-docs"],
readonlyShell: true,
skills: ["factory"],
@@ -133,8 +153,17 @@ export const AGENTS: readonly AgentSpec[] = [
tier: "deep",
description: "Interviews you about an idea until the design tree is settled and the spend ceiling is set.",
prompt: "grill",
tools: ["es_status", "es_frontier_write", "es_request_approval"],
spawns: [],
tools: [
"es_status",
"es_frontier_write",
"es_request_approval",
"es_brainstorm_plan",
"es_brainstorm_record",
"es_brainstorm_complete",
"es_harvest_target",
"es_harvest_prior_art",
],
spawns: [...BRAINSTORM_FANOUT_SPAWNS],
writes: [],
readonlyShell: true,
skills: ["grill"],
@@ -150,8 +179,14 @@ export const AGENTS: readonly AgentSpec[] = [
tier: "deep",
description: "Fans out divergent lenses on an idea and returns a diversified shortlist.",
prompt: "brainstormer",
tools: ["es_status", "es_brainstorm_plan", "es_brainstorm_record", "es_brainstorm_complete"],
spawns: ["es-brainstorm-critic", ...LENSES.map((lens) => lensAgentId(lens.id))],
tools: [
"es_status",
"es_brainstorm_plan",
"es_brainstorm_record",
"es_brainstorm_complete",
"es_harvest_prior_art",
],
spawns: [...BRAINSTORM_FANOUT_SPAWNS],
writes: ["brainstorm"],
readonlyShell: true,
skills: ["brainstorm"],
@@ -176,6 +211,7 @@ export const AGENTS: readonly AgentSpec[] = [
"es_harvest_matrix",
"es_harvest_complete",
"es_harvest_prior_art",
"es_harvest_target",
],
spawns: [],
writes: ["harvest"],
@@ -228,6 +264,7 @@ export const AGENTS: readonly AgentSpec[] = [
"es_scout_advisories",
"es_scout_record",
"es_scout_complete",
"es_harvest_target",
"es_research_search",
"es_research_fetch",
],
@@ -341,6 +378,40 @@ export const AGENTS: readonly AgentSpec[] = [
lsp: "none",
web: "cached",
},
{
id: "deep-researcher",
seat: "deep-researcher",
mode: "primary",
hidden: false,
tier: "deep",
description: "Runs a research-only run adversarially: thesis, antithesis, then a synthesized report.",
prompt: "deep-researcher",
tools: ["es_status", "es_research_search", "es_research_fetch", "es_research_audit", "es_research_complete"],
spawns: ["es-research-alpha", "es-research-beta", "es-research-synthesizer"],
writes: ["deep-research"],
readonlyShell: true,
skills: [],
mcp: [],
lsp: "none",
web: "cached",
},
{
id: "es-research-synthesizer",
seat: "research-synthesizer",
mode: "subagent",
hidden: true,
tier: "deep",
description: "Research synthesizer: resolves alpha/beta disputes into the grounded report.",
prompt: "research-synthesizer",
tools: ["es_status", "es_research_fetch", "es_research_audit"],
spawns: [],
writes: ["research-report"],
readonlyShell: true,
skills: [],
mcp: [],
lsp: "none",
web: "cached",
},
{
id: "es-auditor-thesis",
seat: "auditor-thesis",
+84
View File
@@ -0,0 +1,84 @@
// Approval attempt limiting (ADR 0002 I4): at most 5 wrong passphrases per
// 10 minutes per state dir, then a lockout. Lockouts are audited into the
// project's audit log. One surface's lockout does not affect the others.
import path from "node:path"
import { appendAuditEntry } from "../audit/chain.ts"
import { readJson, withLock, writeJson } from "../util/fs.ts"
/** Wrong passphrases before a lockout. */
export const MAX_APPROVAL_ATTEMPTS = 5
/** Window the attempts are counted in. */
export const APPROVAL_ATTEMPT_WINDOW_MS = 10 * 60_000
const ATTEMPTS_FILE = "approval-attempts.json"
interface AttemptRecord {
readonly failures: string[]
}
export interface AttemptCheck {
readonly allowed: boolean
/** Seconds until the oldest failure leaves the window (present when locked). */
readonly retryAfterSec?: number
/** Wrong attempts left before a lockout (present when allowed). */
readonly remaining?: number
}
const prune = (failures: string[], now: number): string[] =>
failures.filter((at) => {
const time = Date.parse(at)
return !Number.isNaN(time) && time > now - APPROVAL_ATTEMPT_WINDOW_MS && time <= now
})
async function readAttempts(dir: string, now: number): Promise<string[]> {
const record = await readJson<AttemptRecord>(path.join(dir, ATTEMPTS_FILE)).catch(() => undefined)
return prune(record?.failures ?? [], now)
}
function checkFailures(failures: string[], now: number): AttemptCheck {
if (failures.length < MAX_APPROVAL_ATTEMPTS)
return { allowed: true, remaining: MAX_APPROVAL_ATTEMPTS - failures.length }
const oldest = Math.min(...failures.map((at) => Date.parse(at)))
return { allowed: false, retryAfterSec: Math.max(1, Math.ceil((oldest + APPROVAL_ATTEMPT_WINDOW_MS - now) / 1000)) }
}
/** Is a passphrase attempt currently allowed for this state dir? */
export async function checkApprovalAttempts(dir: string, now = Date.now()): Promise<AttemptCheck> {
return checkFailures(await readAttempts(dir, now), now)
}
/** A correct passphrase clears the failure count. */
export async function noteApprovalSuccess(dir: string): Promise<void> {
const file = path.join(dir, ATTEMPTS_FILE)
await withLock(file, async () => writeJson(file, { failures: [] }))
}
export interface WrongPassphraseInput {
readonly dir: string
/** Project root whose audit log records the lockout. */
readonly root: string
readonly stage: string
readonly channel: string
readonly now?: number
}
/**
* Record a wrong passphrase. Tripping the limit locks the surface out and
* audits `approval.lockout` (actor `system`). Returns the post-write check.
*/
export async function noteWrongPassphrase(input: WrongPassphraseInput): Promise<AttemptCheck> {
const now = input.now ?? Date.now()
const file = path.join(input.dir, ATTEMPTS_FILE)
const check = await withLock(file, async () => {
const failures = [...(await readAttempts(input.dir, now)), new Date(now).toISOString()]
await writeJson(file, { failures })
return checkFailures(prune(failures, now), now)
})
if (!check.allowed)
await appendAuditEntry(input.root, {
actor: "system",
action: "approval.lockout",
payload: { stage: input.stage, channel: input.channel, retryAfterSec: check.retryAfterSec },
})
return check
}
+2
View File
@@ -1,2 +1,4 @@
export * from "./attempts.ts"
export * from "./keystore.ts"
export * from "./record.ts"
export * from "./service.ts"
+29 -14
View File
@@ -32,6 +32,12 @@ const SCRYPT = { N: 2 ** 16, r: 8, p: 1, maxmem: 128 * 1024 * 1024 } as const
export class HumanKeyError extends Error {}
/** Zero a code-point array in place (best effort in JS: overwrite + detach). */
export function zeroCodepoints(codepoints: string[]): void {
for (let i = 0; i < codepoints.length; i++) codepoints[i] = ""
codepoints.length = 0
}
/** A signature on a human record. */
export interface RecordSignature {
readonly alg: "ed25519"
@@ -43,6 +49,13 @@ export interface RecordSignature {
export interface HumanSigner {
readonly keyId: string
sign(record: Record<string, unknown>): RecordSignature
/**
* Best-effort end of use: zeroes the retained private-key bytes and makes
* further sign calls throw, so a finished flow cannot sign again. The JS
* engine may keep copies (the passphrase string, OpenSSL internals) until
* GC; callers still drop every reference they hold.
*/
destroy(): void
}
interface SealedKey {
@@ -156,22 +169,31 @@ export async function unlockHumanKey(passphrase: string, dir = stateDir()): Prom
if (!pub || keyIdOf(pub) !== sealed.keyId)
throw new HumanKeyError(`${PUBLIC_FILE} does not match the sealed key (tampered or partial); re-seal by hand`)
let privateKey: KeyObject
let der: Buffer
try {
const key = await derive(passphrase, Buffer.from(sealed.kdf.salt, "base64"), sealed.kdf)
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(sealed.cipher.iv, "base64"))
decipher.setAuthTag(Buffer.from(sealed.cipher.tag, "base64"))
const der = Buffer.concat([decipher.update(Buffer.from(sealed.data, "base64")), decipher.final()])
der = Buffer.concat([decipher.update(Buffer.from(sealed.data, "base64")), decipher.final()])
privateKey = createPrivateKey({ key: der, format: "der", type: "pkcs8" })
} catch {
throw new HumanKeyError("wrong passphrase")
}
let destroyed = false
return {
keyId: sealed.keyId,
sign: (record) => ({
alg: "ed25519",
keyId: sealed.keyId,
sig: sign(null, signedBody(record), privateKey).toString("base64"),
}),
sign: (record) => {
if (destroyed) throw new HumanKeyError("the human key was destroyed after use; unlock it again")
return {
alg: "ed25519",
keyId: sealed.keyId,
sig: sign(null, signedBody(record), privateKey).toString("base64"),
}
},
destroy: () => {
destroyed = true
der.fill(0)
},
}
}
@@ -196,14 +218,7 @@ export async function signatureProblem(
): Promise<string | undefined> {
if (await verifyRecordSignature(record, dir)) return undefined
if (typeof record.mac === "string" && record.signature === undefined)
return `${what} was signed by the pre-1.1.1 approval key, which agents could read; record it again (a human, at a terminal, with the passphrase)`
return `${what} was signed by the pre-1.1.1 approval key, which agents could read; approve it again with /es-approve (or \`es approve\` in a terminal)`
if (!(await hasHumanKey(dir))) return `${what} cannot be verified: ${NO_KEY}`
return `${what} is not signed by this machine's human key (forged, tampered or foreign)`
}
/** A short code a human must type back to confirm a non-signing action (CLI/TUI). */
export function confirmationCode(): string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
const bytes = randomBytes(6)
return Array.from(bytes, (byte) => alphabet[byte % alphabet.length]).join("")
}
+4 -3
View File
@@ -1,7 +1,8 @@
// Human-only approvals. Callers never name the artifact: the subject is
// derived from the factory layout for the stage, validated, hashed and signed
// with the human key, which only the human's passphrase unlocks (1.1.1). Only
// the CLI (a TTY + the passphrase) calls recordApproval; no agent tool, MCP
// the approval service (`approval/service.ts`, called by every surface after
// a passphrase unlock) calls recordApproval; no agent tool, MCP
// tool or RPC does. Transitions call verifyApproval, which re-hashes
// the current artifacts, so editing a spec after approval blocks the build.
import { readFile } from "node:fs/promises"
@@ -9,7 +10,7 @@ import { hostname, userInfo } from "node:os"
import path from "node:path"
import { appendAuditEntry, auditHead } from "../audit/chain.ts"
import { factoryLayout, stateDir } from "../layout.ts"
import { type Approval, type ApprovalChannel, ApprovalSchema, type ApprovalStage } from "../schema/approval.ts"
import { type Approval, ApprovalSchema, type ApprovalStage, type Channel } from "../schema/approval.ts"
import { type Frontier, FrontierSchema } from "../schema/frontier.ts"
import { type ParsedGoal, parseGoalMarkdown } from "../schema/goal.ts"
import { type Roadmap, RoadmapSchema } from "../schema/roadmap.ts"
@@ -105,7 +106,7 @@ export async function approvalSubject(root: string, stage: ApprovalStage): Promi
export interface RecordApprovalInput {
readonly stage: ApprovalStage
readonly channel: ApprovalChannel
readonly channel: Channel
/** Defaults to the OS user running the CLI/TUI. */
readonly approvedBy?: string
readonly notes?: string
+110
View File
@@ -0,0 +1,110 @@
// The one approval code path in core (#117). Every surface (CLI, TUI, web)
// records approvals through approveStage after unlocking the human key with
// the passphrase; nothing copies the record → clear-pending → begin-research
// sequence by hand. The service never sees the passphrase: it receives the
// unlocked HumanSigner.
import { userInfo } from "node:os"
import { Factory } from "../factory/machine.ts"
import type { Stage } from "../factory/state.ts"
import { gateRunner } from "../gates/index.ts"
import { factoryLayout } from "../layout.ts"
import type { Approval, ApprovalStage, Channel } from "../schema/approval.ts"
import { readJson, withLock, writeJson } from "../util/fs.ts"
import { hashJson } from "../util/hash.ts"
import type { HumanSigner } from "./keystore.ts"
import { ApprovalError, approvalSubject, readApproval, recordApproval, verifyApproval } from "./record.ts"
export interface ApproveStageInput {
readonly stage: ApprovalStage
readonly channel: Channel
/** The unlocked human key (the service never handles the passphrase). */
readonly signer: HumanSigner
/** Defaults to the OS user running the surface. */
readonly approvedBy?: string
readonly notes?: string
readonly stateDir?: string
/**
* ADR 0002 I3: hash of the previewed subject (`hashJson` of the subject
* array, as bound into the preview ticket). Refuses on mismatch, so a
* surface signs only what it previewed.
*/
readonly expectedSubjectHash?: string
}
export interface ApproveStageResult {
readonly record: Approval
/** True when a valid record for the unchanged subject already existed (re-used, not re-signed). */
readonly alreadyApproved: boolean
/** Factory stage after the post-approval steps (undefined when no run exists yet). */
readonly factoryStage: Stage | undefined
}
const subjectsEqual = (a: Approval["subject"], b: Approval["subject"]): boolean =>
a.length === b.length && a.every((item, i) => item.path === b[i]?.path && item.sha256 === b[i]?.sha256)
/**
* Record a frontier or spec approval and run its side effects, in order:
* re-derive the subject (with the optional preview-hash check), sign and
* record it, clear the stage from pending approvals, then for frontier
* begin the run and enter research.
*
* The subject check, the record and the pending clear hold the factory state
* lock together. The research begin follows under the Factory's own locks:
* the state lock is not reentrant, so it cannot nest inside this one. The
* window is the same one the CLI has always had; the record and the pending
* clear are atomic with each other, which is what a retry needs.
*
* Failure policy: the signed record is never destroyed. When a later step
* fails, the pending entry stays, and retrying re-uses the record
* (`alreadyApproved`) while still ensuring the pending clear and the
* research begin — the service is self-healing.
*/
export async function approveStage(root: string, input: ApproveStageInput): Promise<ApproveStageResult> {
const approvedBy = input.approvedBy ?? userInfo().username
const actor = `human:${approvedBy}`
const factoryFor = () =>
new Factory(root, {
gates: gateRunner(input.stateDir ? { stateDir: input.stateDir } : {}),
...(input.stateDir ? { stateDir: input.stateDir } : {}),
})
const record = await withLock(factoryLayout(root).state, async () => {
const { subject } = await approvalSubject(root, input.stage)
if (input.expectedSubjectHash !== undefined && hashJson(subject) !== input.expectedSubjectHash)
throw new ApprovalError("What was previewed changed since the preview; preview again and re-approve.")
// Idempotency: a valid record for the unchanged subject is re-used.
// The pending clear below still runs, so a retry after a midway failure heals.
const existing = await readApproval(root, input.stage)
let record: Approval
let alreadyApproved = false
if (
existing &&
subjectsEqual(existing.subject, subject) &&
(await verifyApproval(root, input.stage, { stateDir: input.stateDir })).ok
) {
record = existing
alreadyApproved = true
} else {
record = await recordApproval(root, {
stage: input.stage,
channel: input.channel,
approvedBy,
...(input.notes ? { notes: input.notes } : {}),
signer: input.signer,
})
}
const pending = (await readJson<Array<{ stage: string }>>(factoryLayout(root).pending).catch(() => undefined)) ?? []
await writeJson(
factoryLayout(root).pending,
pending.filter((item) => item.stage !== input.stage),
)
return { record, alreadyApproved }
})
if (input.stage === "frontier") {
const factory = factoryFor()
if (!(await factory.read())) await factory.begin(actor)
if ((await factory.read())?.stage === "GRILL") await factory.beginResearch(actor)
}
return { ...record, factoryStage: (await factoryFor().read())?.stage }
}
+140 -65
View File
@@ -17,10 +17,14 @@ import {
BrainstormScoreSchema,
RUBRIC,
} from "../schema/brainstorm.ts"
import { withLock } from "../util/fs.ts"
import { createEmbedder, type EmbeddingsConfig } from "./embed.ts"
import { collapseDuplicates, collapseDuplicatesEmbedded, rankIdeas, selectShortlist } from "./engine.ts"
import { buildPlan, LENSES, type PlanOptions } from "./lenses.ts"
import {
brainstormRunPaths,
DEFAULT_RUN,
parseRunId,
readIdeas,
readPlan,
readScores,
@@ -47,6 +51,29 @@ const now = () => new Date().toISOString()
const list = (items: readonly string[], limit = 8) =>
items.length > limit ? `${items.slice(0, limit).join(", ")} (+${items.length - limit} more)` : items.join(", ")
/**
* Seats that may run a brainstorm: the brainstormer (human /brainstorm) and
* the grill/factory seats, which fan out the lens subagents themselves at
* depth 1 and record what comes back (#108). The critic only scores.
*/
const mayRun = (agent: string | undefined): boolean => {
const seat = seatOf(agent)
return seat === "brainstormer" || seat === "grill" || seat === "factory"
}
/** Spend guard (#108): callers other than the brainstormer get lean defaults unless they ask for more. */
const LEAN_LENSES = 4
const LEAN_IDEAS_PER_LENS = 3
const LEAN_SHORTLIST_SIZE = 5
const runRefusal = (verb: string) =>
new ToolRefusal(`Only the brainstormer seat (or the grill/factory seats running a callable brainstorm) may ${verb}.`)
const runOf = (input: { run?: unknown }): string => {
try {
return parseRunId(input.run)
} catch (error) {
throw new ToolRefusal(error instanceof Error ? error.message : String(error))
}
}
export function brainstormTools(context: BrainstormOpsContext): EsToolDef[] {
const { root } = context
const embed = context.embeddings ? createEmbedder(context.embeddings) : undefined
@@ -66,12 +93,16 @@ export function brainstormTools(context: BrainstormOpsContext): EsToolDef[] {
{
name: "es_brainstorm_plan",
description:
"Brainstormer only: freeze the lens set, idea caps and shortlist size for a new brainstorm. Returns the subagents to launch.",
"Brainstormer, grill or factory: freeze the lens set, idea caps and shortlist size for a brainstorm run. Returns the run id and the subagents to launch (launch every lens in one message, in the foreground).",
input: object(
{
idea: { type: "string", description: "The brief in one or two sentences." },
context: { type: "string", description: "Optional background that every lens should see." },
constraints: { type: "array", items: { type: "string" } },
run: {
type: "string",
description: `Run id for this brainstorm (lowercase/dashes; default "${DEFAULT_RUN}"). Several runs can coexist.`,
},
lenses: {
type: "array",
items: { type: "string" },
@@ -94,17 +125,18 @@ export function brainstormTools(context: BrainstormOpsContext): EsToolDef[] {
["idea"],
),
execute: async (input, toolContext) => {
if (seatOf(toolContext.agent) !== "brainstormer")
throw new ToolRefusal("Only the brainstormer seat may plan a brainstorm.")
if (!mayRun(toolContext.agent)) throw runRefusal("plan a brainstorm")
const run = runOf(input)
const lean = seatOf(toolContext.agent) !== "brainstormer"
const brief = BrainstormBriefSchema.parse({
idea: String(input.idea ?? "").trim(),
...(input.context ? { context: String(input.context) } : {}),
...(Array.isArray(input.constraints) ? { constraints: input.constraints.map(String) } : {}),
})
const existing = await readPlan(root).catch((error: Error) => {
const existing = await readPlan(root, run).catch((error: Error) => {
throw new ToolRefusal(`Cannot read the current plan: ${error.message}`)
})
const ideas = existing ? await readIdeas(root) : []
const ideas = existing ? await readIdeas(root, run) : []
if (existing && ideas.length && input.force !== true)
throw new ToolRefusal(
`A brainstorm is already in progress (${ideas.length} idea(s) recorded). Pass force:true to replace it, or finish it with es_brainstorm_complete.`,
@@ -112,27 +144,39 @@ export function brainstormTools(context: BrainstormOpsContext): EsToolDef[] {
let built: ReturnType<typeof buildPlan>
try {
built = buildPlan(brief, {
...(Array.isArray(input.lenses) ? { lenses: input.lenses.map(String) } : {}),
...(input.ideasPerLens !== undefined ? { ideasPerLens: Number(input.ideasPerLens) } : {}),
...(input.shortlistSize !== undefined ? { shortlistSize: Number(input.shortlistSize) } : {}),
...(Array.isArray(input.lenses)
? { lenses: input.lenses.map(String) }
: lean
? { lenses: LENSES.slice(0, LEAN_LENSES).map((lens) => lens.id) }
: {}),
...(input.ideasPerLens !== undefined
? { ideasPerLens: Number(input.ideasPerLens) }
: lean
? { ideasPerLens: LEAN_IDEAS_PER_LENS }
: {}),
...(input.shortlistSize !== undefined
? { shortlistSize: Number(input.shortlistSize) }
: lean
? { shortlistSize: LEAN_SHORTLIST_SIZE }
: {}),
...(input.dedupe ? { dedupeMode: String(input.dedupe) as "ngram" | "minhash" | "embedding" } : {}),
} satisfies PlanOptions)
} catch (error) {
throw new ToolRefusal(error instanceof Error ? error.message : String(error))
}
await startRun(root, built.plan)
await startRun(root, built.plan, run)
const ids = built.lenses.map((lens) => `es-lens-${lens.id}`)
return [
`Brainstorm planned: "${built.plan.brief.idea}"`,
`Brainstorm planned (run "${run}"): "${built.plan.brief.idea}"`,
`Lenses (${built.lenses.length}): ${built.lenses.map((lens) => `${lens.id} (${lens.name})`).join(", ")}`,
`Caps: ≤${built.plan.ideasPerLens} ideas per lens, ≤${built.plan.maxIdeaChars} characters each; shortlist ${built.plan.shortlistSize}.`,
"",
"Next steps:",
`1. Launch every lens subagent in parallel by exact ID: ${ids.join(", ")}.`,
" Give each one the brief (and constraints) plus how many ideas you expect.",
"2. Record each lens's ideas with es_brainstorm_record (one call per lens).",
`2. Record each lens's ideas with es_brainstorm_record (one call per lens, run "${run}").`,
"3. Launch es-brainstorm-critic with the surviving idea ids and texts; it scores each with es_brainstorm_score.",
`4. Call es_brainstorm_complete. Dedupe, ranking and the shortlist (with one forced outlier) are computed there.`,
`4. Call es_brainstorm_complete with run "${run}". Dedupe, ranking and the shortlist (with one forced outlier) are computed there.`,
"",
`Rubric (1-5 each): ${RUBRIC.map((item) => item.id).join(", ")}.`,
].join("\n")
@@ -141,10 +185,11 @@ export function brainstormTools(context: BrainstormOpsContext): EsToolDef[] {
{
name: "es_brainstorm_record",
description:
"Brainstormer only: record one lens's ideas. Rejects caps violations, near-duplicates are collapsed against earlier ideas.",
"Brainstormer, grill or factory: record one lens's ideas for a run. Rejects caps violations, near-duplicates are collapsed against earlier ideas.",
input: object(
{
lens: { type: "string" },
run: { type: "string", description: "Run id from es_brainstorm_plan." },
ideas: {
type: "array",
items: object({
@@ -157,9 +202,9 @@ export function brainstormTools(context: BrainstormOpsContext): EsToolDef[] {
["lens", "ideas"],
),
execute: async (input, toolContext) => {
if (seatOf(toolContext.agent) !== "brainstormer")
throw new ToolRefusal("Only the brainstormer seat may record ideas.")
const plan = await readPlan(root).catch((error: Error) => {
if (!mayRun(toolContext.agent)) throw runRefusal("record ideas for a brainstorm")
const run = runOf(input)
const plan = await readPlan(root, run).catch((error: Error) => {
throw new ToolRefusal(`Cannot read the plan: ${error.message}`)
})
if (!plan) throw new ToolRefusal("No brainstorm plan. Call es_brainstorm_plan first.")
@@ -168,42 +213,51 @@ export function brainstormTools(context: BrainstormOpsContext): EsToolDef[] {
throw new ToolRefusal(`"${lens}" is not in this plan. Planned lenses: ${plan.lenses.join(", ")}.`)
const batch = Array.isArray(input.ideas) ? input.ideas : []
if (!batch.length) throw new ToolRefusal("Give at least one idea.")
const existing = await readIdeas(root)
const used = existing.filter((idea) => idea.lens === lens).length
if (used + batch.length > plan.ideasPerLens)
throw new ToolRefusal(
`The "${lens}" lens already has ${used} idea(s); recording ${batch.length} more exceeds the cap of ${plan.ideasPerLens}.`,
// Locked read → mutate → write on the ideas file: two lenses
// recording at once must not lose each other's ideas or mint the
// same id twice. The write itself stays atomic (writeJson).
const { stored, recorded, byId } = await withLock(brainstormRunPaths(root, run).ideas, async () => {
const existing = await readIdeas(root, run)
const used = existing.filter((idea) => idea.lens === lens).length
if (used + batch.length > plan.ideasPerLens)
throw new ToolRefusal(
`The "${lens}" lens already has ${used} idea(s); recording ${batch.length} more exceeds the cap of ${plan.ideasPerLens}.`,
)
let seq = existing.reduce(
(max, idea) => Math.max(max, Number.parseInt(idea.id.replace(/^b/, ""), 10) || 0),
0,
)
let seq = existing.reduce((max, idea) => Math.max(max, Number.parseInt(idea.id.replace(/^b/, ""), 10) || 0), 0)
const recorded: BrainstormIdea[] = []
for (const [index, raw] of batch.entries()) {
const title = String(raw?.title ?? "").trim()
const text = String(raw?.text ?? "").trim()
if (title.length < 3) throw new ToolRefusal(`Idea ${index + 1}: title is too short.`)
if (title.length > 200) throw new ToolRefusal(`Idea ${index + 1}: title is too long (200 characters).`)
if (text.length < 40) throw new ToolRefusal(`Idea ${index + 1}: text is too short; explain the idea.`)
if (text.length > plan.maxIdeaChars)
throw new ToolRefusal(`Idea ${index + 1}: text exceeds ${plan.maxIdeaChars} characters.`)
seq += 1
recorded.push({
id: `b${String(seq).padStart(3, "0")}`,
lens,
title,
text,
...(raw?.notes ? { notes: String(raw.notes) } : {}),
recordedAt: now(),
const recorded: BrainstormIdea[] = []
for (const [index, raw] of batch.entries()) {
const title = String(raw?.title ?? "").trim()
const text = String(raw?.text ?? "").trim()
if (title.length < 3) throw new ToolRefusal(`Idea ${index + 1}: title is too short.`)
if (title.length > 200) throw new ToolRefusal(`Idea ${index + 1}: title is too long (200 characters).`)
if (text.length < 40) throw new ToolRefusal(`Idea ${index + 1}: text is too short; explain the idea.`)
if (text.length > plan.maxIdeaChars)
throw new ToolRefusal(`Idea ${index + 1}: text exceeds ${plan.maxIdeaChars} characters.`)
seq += 1
recorded.push({
id: `b${String(seq).padStart(3, "0")}`,
lens,
title,
text,
...(raw?.notes ? { notes: String(raw.notes) } : {}),
recordedAt: now(),
})
}
const all = [...existing, ...recorded]
const hits = await collapse(all, plan.dedupeMode, plan.dedupeThreshold)
const byId = new Map(hits.map((hit) => [hit.id, hit]))
const stored = all.map((idea) => {
const hit = byId.get(idea.id)
return hit ? { ...idea, duplicateOf: hit.duplicateOf, similarity: hit.similarity } : idea
})
}
const all = [...existing, ...recorded]
const hits = await collapse(all, plan.dedupeMode, plan.dedupeThreshold)
const byId = new Map(hits.map((hit) => [hit.id, hit]))
const stored = all.map((idea) => {
const hit = byId.get(idea.id)
return hit ? { ...idea, duplicateOf: hit.duplicateOf, similarity: hit.similarity } : idea
await writeIdeas(root, stored, run)
return { stored, recorded, byId }
})
await writeIdeas(root, stored)
const lines = [
`Recorded ${recorded.length} idea(s) for "${lens}" (${used + recorded.length}/${plan.ideasPerLens}): ${recorded.map((idea) => idea.id).join(", ")}`,
`Recorded ${recorded.length} idea(s) for "${lens}" (${stored.filter((idea) => idea.lens === lens).length}/${plan.ideasPerLens}): ${recorded.map((idea) => idea.id).join(", ")}`,
]
const dropped = recorded.map((idea) => byId.get(idea.id)).filter((hit) => hit !== undefined)
for (const hit of dropped)
@@ -223,6 +277,7 @@ export function brainstormTools(context: BrainstormOpsContext): EsToolDef[] {
"Critic only: score ideas 1-5 on novelty, upside, feasibility and fit. Re-scoring an id replaces its scores.",
input: object(
{
run: { type: "string", description: "Run id from es_brainstorm_plan." },
scores: {
type: "array",
items: object(
@@ -243,12 +298,13 @@ export function brainstormTools(context: BrainstormOpsContext): EsToolDef[] {
execute: async (input, toolContext) => {
if (seatOf(toolContext.agent) !== "brainstorm-critic")
throw new ToolRefusal("Only the brainstorm-critic seat may score ideas.")
const plan = await readPlan(root).catch((error: Error) => {
const run = runOf(input)
const plan = await readPlan(root, run).catch((error: Error) => {
throw new ToolRefusal(`Cannot read the plan: ${error.message}`)
})
if (!plan) throw new ToolRefusal("No brainstorm plan to score against.")
const ideas = await readIdeas(root)
const scores = await readScores(root)
const ideas = await readIdeas(root, run)
const scores = await readScores(root, run)
const byId = new Map(ideas.map((idea) => [idea.id, idea]))
const batch = Array.isArray(input.scores) ? input.scores : []
if (!batch.length) throw new ToolRefusal("Give at least one score.")
@@ -273,7 +329,7 @@ export function brainstormTools(context: BrainstormOpsContext): EsToolDef[] {
}
const merged = new Map(scores.map((score) => [score.id, score]))
for (const score of incoming) merged.set(score.id, score)
await writeScores(root, [...merged.values()])
await writeScores(root, [...merged.values()], run)
const survivors = ideas.filter((idea) => !idea.duplicateOf)
const missing = survivors.filter((idea) => !merged.has(idea.id))
return [
@@ -288,8 +344,9 @@ export function brainstormTools(context: BrainstormOpsContext): EsToolDef[] {
{
name: "es_brainstorm_complete",
description:
"Brainstormer only: collapse duplicates, rank the scored ideas and write the diversified shortlist with a forced outlier slot.",
"Brainstormer, grill or factory: collapse duplicates, rank the scored ideas and write the diversified shortlist with a forced outlier slot. Returns the shortlist as JSON the caller can parse.",
input: object({
run: { type: "string", description: "Run id from es_brainstorm_plan." },
allowPartial: {
type: "boolean",
description: "Finish even when a planned lens produced no surviving idea (they are recorded as gaps).",
@@ -309,13 +366,13 @@ export function brainstormTools(context: BrainstormOpsContext): EsToolDef[] {
},
}),
execute: async (input, toolContext) => {
if (seatOf(toolContext.agent) !== "brainstormer")
throw new ToolRefusal("Only the brainstormer seat may complete a brainstorm.")
const plan = await readPlan(root).catch((error: Error) => {
if (!mayRun(toolContext.agent)) throw runRefusal("complete a brainstorm")
const run = runOf(input)
const plan = await readPlan(root, run).catch((error: Error) => {
throw new ToolRefusal(`Cannot read the plan: ${error.message}`)
})
if (!plan) throw new ToolRefusal("No brainstorm plan. Call es_brainstorm_plan first.")
const ideas = await readIdeas(root)
const ideas = await readIdeas(root, run)
if (!ideas.length) throw new ToolRefusal("No ideas recorded yet.")
const hits = await collapse(ideas, plan.dedupeMode, plan.dedupeThreshold)
const hitById = new Map(hits.map((hit) => [hit.id, hit]))
@@ -327,16 +384,16 @@ export function brainstormTools(context: BrainstormOpsContext): EsToolDef[] {
throw new ToolRefusal(
`These planned lenses produced no surviving idea: ${gaps.join(", ")}. Record ideas for them, or pass allowPartial:true to finish with gaps.`,
)
const scores = new Map((await readScores(root)).map((score) => [score.id, score]))
const scores = new Map((await readScores(root, run)).map((score) => [score.id, score]))
const unscored = survivors.filter((idea) => !scores.has(idea.id))
if (unscored.length)
throw new ToolRefusal(
`Still unscored: ${list(unscored.map((idea) => idea.id))}. Have es-brainstorm-critic score them (es_brainstorm_score) first.`,
)
// Prior art must come from a recorded es_harvest_prior_art search, not from memory.
// Prior art must come from a recorded es_harvest_prior_art search for this run, not from memory.
const priorArt = Array.isArray(input.priorArt) ? input.priorArt : []
if (priorArt.length) {
const found = await priorArtUrls(root)
const found = await priorArtUrls(root, run)
const unrecorded = priorArt.filter((entry: { url?: unknown }) => !found.has(String(entry?.url ?? "")))
if (unrecorded.length)
throw new ToolRefusal(
@@ -374,18 +431,36 @@ export function brainstormTools(context: BrainstormOpsContext): EsToolDef[] {
},
completedAt: now(),
})
await writeResult(root, result, renderBrainstorm(result))
await writeResult(root, result, renderBrainstorm(result), run)
const byId = new Map(survivors.map((item) => [item.id, item]))
const short = shortlist
.map((entry, index) => {
const idea = survivors.find((item) => item.id === entry.id)!
return `${index + 1}. ${entry.id} (${entry.total}/20)${entry.outlier ? " · outlier" : ""} — ${idea.title}`
const item = byId.get(entry.id)!
return `${index + 1}. ${entry.id} (${entry.total}/20)${entry.outlier ? " · outlier" : ""} — ${item.title}`
})
.join("\n")
const shortlistJson = JSON.stringify({
run,
shortlist: shortlist.map((entry) => {
const item = byId.get(entry.id)!
return {
id: entry.id,
title: item.title,
lens: item.lens,
total: entry.total,
outlier: entry.outlier,
...(entry.reason ? { reason: entry.reason } : {}),
}
}),
})
return [
`Brainstorm complete: ${survivors.length} ideas, ${hits.length} duplicate(s) collapsed, ${plan.lenses.length} lenses.`,
`Brainstorm complete (run "${run}"): ${survivors.length} ideas, ${hits.length} duplicate(s) collapsed, ${plan.lenses.length} lenses.`,
`Shortlist (${shortlist.length}):`,
short,
`Written: ${factoryLayout(root).dir}/brainstorm/brainstorm.json and BRAINSTORM.md`,
`Written: ${factoryLayout(root).dir}/brainstorm/runs/${run}/brainstorm.json and BRAINSTORM.md`,
"",
"--- shortlist (JSON) ---",
shortlistJson,
].join("\n")
},
},
+86 -23
View File
@@ -1,7 +1,7 @@
// Reading, writing and rendering the brainstorm artifacts under
// .factory/brainstorm/: plan.json, ideas.json, scores.json, brainstorm.json
// and the human-readable BRAINSTORM.md.
import { rm } from "node:fs/promises"
import { readdir, rm, stat } from "node:fs/promises"
import { factoryLayout } from "../layout.ts"
import {
type BrainstormIdea,
@@ -27,57 +27,120 @@ export function brainstormPaths(root: string) {
}
}
/** The human's /brainstorm always runs here; callers pass their own run id. */
export const DEFAULT_RUN = "default" as const
const RUN_ID = /^[a-z0-9][a-z0-9-]{0,39}$/
/** Validate a run id (tool input): kebab-case, so it is always a safe path segment. */
export function parseRunId(input: unknown): string {
if (input === undefined) return DEFAULT_RUN
const run = String(input).trim() || DEFAULT_RUN
if (!RUN_ID.test(run)) throw new Error(`invalid brainstorm run id "${run}": use lowercase letters, digits and dashes`)
return run
}
/** Artifact paths for one run under .factory/brainstorm/runs/<runId>/ (#108). */
export function brainstormRunPaths(root: string, runId: string = DEFAULT_RUN) {
const dir = factoryLayout(root).brainstormRun(runId)
return {
dir,
plan: factoryLayout(root).brainstormRunPlan(runId),
ideas: factoryLayout(root).brainstormRunIdeas(runId),
scores: factoryLayout(root).brainstormRunScores(runId),
result: factoryLayout(root).brainstormRunResult(runId),
report: factoryLayout(root).brainstormRunReport(runId),
}
}
/** Every brainstorm run with a plan on disk, oldest first (best effort on mtimes). */
export async function listBrainstormRuns(root: string): Promise<string[]> {
const runs = factoryLayout(root).brainstormRuns
const entries = await readdir(runs).catch(() => [] as string[])
const plans: Array<{ run: string; at: number }> = []
for (const entry of entries) {
if (!RUN_ID.test(entry)) continue
const plan = brainstormRunPaths(root, entry).plan
const info = await stat(plan).catch(() => undefined)
if (info) plans.push({ run: entry, at: info.mtimeMs })
}
return plans.sort((a, b) => a.at - b.at).map((item) => item.run)
}
const invalid = (file: string, error: unknown) =>
new Error(`invalid ${file}: ${error instanceof Error ? error.message : String(error)}`)
export async function readPlan(root: string): Promise<BrainstormPlan | undefined> {
const raw = await readJson(brainstormPaths(root).plan)
export async function readPlan(root: string, runId: string = DEFAULT_RUN): Promise<BrainstormPlan | undefined> {
const raw = await readJson(brainstormRunPaths(root, runId).plan)
if (raw === undefined && runId === DEFAULT_RUN) {
// Pre-runs checkouts keep working: legacy top-level files read as the default run (#108).
const legacy = await readJson(brainstormPaths(root).plan)
if (legacy === undefined) return undefined
const parsedLegacy = BrainstormPlanSchema.safeParse(legacy)
if (!parsedLegacy.success) throw invalid("plan.json", parsedLegacy.error)
return parsedLegacy.data
}
if (raw === undefined) return undefined
const parsed = BrainstormPlanSchema.safeParse(raw)
if (!parsed.success) throw invalid("plan.json", parsed.error)
return parsed.data
}
export async function readIdeas(root: string): Promise<BrainstormIdea[]> {
const raw = (await readJson(brainstormPaths(root).ideas)) ?? []
export async function readIdeas(root: string, runId: string = DEFAULT_RUN): Promise<BrainstormIdea[]> {
const raw = (await readJson(brainstormRunPaths(root, runId).ideas)) ?? []
const parsed = BrainstormIdeaSchema.array().safeParse(raw)
if (!parsed.success) throw invalid("ideas.json", parsed.error)
return parsed.data
}
export async function readScores(root: string): Promise<BrainstormScore[]> {
const raw = (await readJson(brainstormPaths(root).scores)) ?? []
export async function readScores(root: string, runId: string = DEFAULT_RUN): Promise<BrainstormScore[]> {
const raw = (await readJson(brainstormRunPaths(root, runId).scores)) ?? []
const parsed = BrainstormScoreSchema.array().safeParse(raw)
if (!parsed.success) throw invalid("scores.json", parsed.error)
return parsed.data
}
export async function readResult(root: string): Promise<BrainstormResult | undefined> {
const raw = await readJson(brainstormPaths(root).result)
export async function readResult(root: string, runId: string = DEFAULT_RUN): Promise<BrainstormResult | undefined> {
const raw = await readJson(brainstormRunPaths(root, runId).result)
if (raw === undefined && runId === DEFAULT_RUN) {
const legacy = await readJson(brainstormPaths(root).result)
if (legacy === undefined) return undefined
const parsedLegacy = BrainstormResultSchema.safeParse(legacy)
if (!parsedLegacy.success) throw invalid("brainstorm.json", parsedLegacy.error)
return parsedLegacy.data
}
if (raw === undefined) return undefined
const parsed = BrainstormResultSchema.safeParse(raw)
if (!parsed.success) throw invalid("brainstorm.json", parsed.error)
return parsed.data
}
export const writePlan = (root: string, plan: BrainstormPlan) => writeJson(brainstormPaths(root).plan, plan)
export const writeIdeas = (root: string, ideas: readonly BrainstormIdea[]) =>
writeJson(brainstormPaths(root).ideas, ideas)
export const writeScores = (root: string, scores: readonly BrainstormScore[]) =>
writeJson(brainstormPaths(root).scores, scores)
export const writePlan = (root: string, plan: BrainstormPlan, runId: string = DEFAULT_RUN) =>
writeJson(brainstormRunPaths(root, runId).plan, plan)
export const writeIdeas = (root: string, ideas: readonly BrainstormIdea[], runId: string = DEFAULT_RUN) =>
writeJson(brainstormRunPaths(root, runId).ideas, ideas)
export const writeScores = (root: string, scores: readonly BrainstormScore[], runId: string = DEFAULT_RUN) =>
writeJson(brainstormRunPaths(root, runId).scores, scores)
/** Replace the run: freeze the plan and clear ideas, scores and any stale result. */
export async function startRun(root: string, plan: BrainstormPlan): Promise<void> {
await writePlan(root, plan)
await writeIdeas(root, [])
await writeScores(root, [])
await rm(brainstormPaths(root).result, { force: true })
await rm(brainstormPaths(root).report, { force: true })
export async function startRun(root: string, plan: BrainstormPlan, runId: string = DEFAULT_RUN): Promise<void> {
await writePlan(root, plan, runId)
await writeIdeas(root, [], runId)
await writeScores(root, [], runId)
const paths = brainstormRunPaths(root, runId)
await rm(paths.result, { force: true })
await rm(paths.report, { force: true })
}
export async function writeResult(root: string, result: BrainstormResult, report: string): Promise<void> {
await writeJson(brainstormPaths(root).result, result)
await atomicWrite(brainstormPaths(root).report, report.endsWith("\n") ? report : `${report}\n`)
export async function writeResult(
root: string,
result: BrainstormResult,
report: string,
runId: string = DEFAULT_RUN,
): Promise<void> {
const paths = brainstormRunPaths(root, runId)
await writeJson(paths.result, result)
await atomicWrite(paths.report, report.endsWith("\n") ? report : `${report}\n`)
}
/** The human-readable digest: shortlist first, then the full ranked field. */
+16 -7
View File
@@ -28,6 +28,7 @@ export const CAPABILITIES = [
"claims",
"audit",
"scout",
"research-fires",
] as const
export type Capability = (typeof CAPABILITIES)[number]
@@ -65,7 +66,7 @@ export const CAPABILITY_MATRIX: Readonly<Record<Harness, readonly CapabilityRow[
support: "enforced",
detail:
"permission.evaluate + tool.execute.before enforce the write/read/shell policy; control files (incl. the engine-sealed evidence cache) denied; research seats get no host websearch/webfetch. Every agent shell runs under bubblewrap by seat kind (cached-web seats offline); seats are refused without it and the user's agents fall back to the weaker argv-aware text policy.",
test: "packages/core/test/policy.test.ts",
test: "packages/opencode/test/scoping.test.ts",
},
hooks: {
support: "enforced",
@@ -75,13 +76,13 @@ export const CAPABILITY_MATRIX: Readonly<Record<Harness, readonly CapabilityRow[
question: {
support: "enforced",
detail: "Session forms only humans can answer; primaries may ask, autonomous subagent seats may not.",
test: PLUGIN,
test: "packages/opencode/test/scoping.test.ts",
},
subagents: {
support: "enforced",
detail:
"Depth 1; hidden seats are unlisted but invocable by ID, and only from the seats allowed to spawn them. Factory seats are not offered Code Mode execute.",
test: PLUGIN,
test: "packages/opencode/test/scoping.test.ts",
},
compaction: {
support: "enforced",
@@ -108,17 +109,19 @@ export const CAPABILITY_MATRIX: Readonly<Record<Harness, readonly CapabilityRow[
research: {
support: "enforced",
detail:
"Engine-sealed cache (#52), fragment-level quote verifier (every ellipsis fragment >= 12 chars; code excerpts one contiguous span of at most 60 lines), auditor, providers and the websearch bridge.",
test: "packages/core/test/research.test.ts",
"Engine-sealed cache (#52), fragment-level quote verifier (every ellipsis fragment >= 12 chars; code excerpts one contiguous span of at most 60 lines), auditor, providers and the websearch bridge. Research-only runs complete through the deep-researcher (thesis/antithesis/synthesis at depth 1) at DONE.",
test: "packages/opencode/test/research.test.ts",
},
brainstorm: {
support: "enforced",
detail: "Lens fan-out, record/score/complete tools, dedupe, rubric, shortlist with a forced outlier.",
detail:
"Lens fan-out, record/score/complete tools, dedupe, rubric, shortlist with a forced outlier. Runs are id-scoped (the human's /brainstorm is the default run); the grill and the factory run callable brainstorms at depth 1 and get the shortlist as JSON.",
test: "packages/opencode/test/brainstorm.test.ts",
},
harvest: {
support: "enforced",
detail: "Fail-closed SPDX detection, provenance profiles, policy-enforced matrix, clean-room specs.",
detail:
"Fail-closed SPDX detection, provenance profiles, policy-enforced matrix, clean-room specs. Runs are id-scoped (the human's /harvest is the default run); es_harvest_target gives the grill, factory, scout and harvester deterministic verdicts, and prior-art search is shared with brainstorm callers.",
test: "packages/opencode/test/harvest.test.ts",
},
design: {
@@ -145,6 +148,12 @@ export const CAPABILITY_MATRIX: Readonly<Record<Harness, readonly CapabilityRow[
"OSS scout: cached-only web under an offline (--unshare-net) sandbox, fail-closed license verdicts computed by core (adopt only for verified permissive licenses), OSV advisories cited.",
test: "packages/opencode/test/fires.test.ts",
},
"research-fires": {
support: "enforced",
detail:
"Research fires: the whole research loop on the real host with loopback-only servers — grounded quotes survive the audit, failover records provenance, blocked never fails over, planted sources are refused, retractions degrade to STALE, renders snapshot.",
test: "packages/opencode/test/fires-research.test.ts",
},
}),
claude: [
...CAPABILITIES.map((capability) => ({
+4 -2
View File
@@ -15,7 +15,7 @@
import { readFile } from "node:fs/promises"
import { type HumanSigner, humanKeyId, verifyRecordSignature } from "../approval/keystore.ts"
import { factoryLayout, stateDir } from "../layout.ts"
import { researchSourcesDir, SourceCache } from "../research/cache.ts"
import { researchCache } from "../research/ops.ts"
import { verifyQuote } from "../research/quote.ts"
import { type Brief, BriefSchema, type Claim } from "../schema/claims.ts"
import { atomicWrite, readJson } from "../util/fs.ts"
@@ -30,6 +30,8 @@ export interface ExportBriefOptions {
readonly signer: HumanSigner
/** Where to write the brief (default .factory/research/brief.pcrb.json). */
readonly out?: string
/** Engine-key dir for the sealed source cache (#98); defaults to the global state dir. */
readonly stateDir?: string
readonly now?: () => Date
}
@@ -55,7 +57,7 @@ export async function exportBrief(root: string, options: ExportBriefOptions): Pr
const store = await ClaimStore.load(root)
const research = store.dossiers.find((item) => item.dossier.mode === "research")
if (!research) throw new Error("No research dossier yet: es_research_complete writes .factory/research/dossier.json.")
const cache = new SourceCache(researchSourcesDir(root))
const cache = researchCache(root, options.stateDir ?? stateDir())
const now = options.now ?? (() => new Date())
const known = new Set(research.dossier.claims.map((claim) => claim.id))
const witnesses = new Map(
+5 -2
View File
@@ -20,6 +20,8 @@ export interface ClaimInput {
readonly query?: string
readonly finding?: string
readonly severity?: Claim["severity"]
/** Source-quality tier; carried through, never part of the claim id (#113). */
readonly tier?: string
}
/** The claim is malformed; the message says why (model- and human-facing). */
@@ -42,8 +44,8 @@ const cap = (text: string, max = NK_MAX_FIELD) => {
return points.length > max ? points.slice(0, max).join("") : text
}
/** Content fields only: status and witness change over time, the claim does not. */
export function claimId(claim: Omit<Claim, "id" | "status" | "witness">): string {
/** Content fields only: status, witness and tier change over time, the claim does not. */
export function claimId(claim: Omit<Claim, "id" | "status" | "witness" | "tier">): string {
const { tag, statement, source, location, parents, reasoning, falsification, query, finding, nkKey, severity } = claim
return hashJson({
tag,
@@ -119,6 +121,7 @@ export function normalizeClaim(raw: ClaimInput): Claim {
...(raw.falsification?.trim() ? { falsification: raw.falsification.trim() } : {}),
...(tag === "NEGATIVE_KNOWLEDGE" ? { query, finding, ...(nkKey ? { nkKey } : {}) } : {}),
...(raw.severity ? { severity: raw.severity } : {}),
...(raw.tier?.trim() ? { tier: raw.tier.trim() } : {}),
}
const parsed = ClaimSchema.safeParse({ id: claimId(content), status: "LIVE", ...content })
if (!parsed.success)
+23
View File
@@ -49,6 +49,29 @@ export const weightFor = (constitution: Constitution, tier: string | undefined):
? constitution.tierWeights[tier]
: constitution.tierWeights.__default__) ?? 1
/**
* The tier evidence composes at (#113): the highest-weighted tier wins, ties
* keep the first listed, and no tier means __default__. Claims built from one
* cited source carry that source's tier; multi-source evidence (scout/audit
* modes) resolves through here under the active pack.
*/
export function highestTier(
tiers: readonly (string | undefined)[],
constitution: Constitution = LEGACY_CONSTITUTION,
): string | undefined {
let best: string | undefined
let bestWeight = -Infinity
for (const tier of tiers) {
if (tier === undefined) continue
const weight = weightFor(constitution, tier)
if (weight > bestWeight) {
best = tier
bestWeight = weight
}
}
return best
}
const PACKS = fileURLToPath(new URL("../../assets/domain-packs/", import.meta.url))
/**
+1
View File
@@ -4,6 +4,7 @@ export * from "./constitution.ts"
export * from "./degrade.ts"
export * from "./dossier.ts"
export * from "./rank.ts"
export * from "./render.ts"
export * from "./research.ts"
export * from "./store.ts"
export * from "./witness.ts"
+276
View File
@@ -0,0 +1,276 @@
// Deterministic dossier renders (#112): Markdown and one self-contained HTML
// file from a dossier, its report and its sources. Rendering signs nothing,
// so it is agent-safe; the signed PCRB brief stays human-only.
//
// Source text is untrusted: the HTML escapes everything, links only http(s)
// URLs (with rel=noopener), and loads no external resources, fonts, images or
// scripts. Both formats are byte-deterministic for snapshot tests.
import { readFile } from "node:fs/promises"
import { stateDir as defaultStateDir, factoryLayout } from "../layout.ts"
import type { SourceMeta } from "../research/cache.ts"
import { researchCache } from "../research/ops.ts"
import { readEngineKey, verifyMetaSeal } from "../research/seal.ts"
import type { Claim } from "../schema/claims.ts"
import { applyDegradation, readRetractions } from "./degrade.ts"
import { readDossier } from "./dossier.ts"
export interface RenderSource {
readonly meta: SourceMeta
readonly text: string
}
export interface DossierRenderInput {
readonly objective: string
/** REPORT.md text. */
readonly report: string
/** Claims after applyDegradation (statuses already resolved). */
readonly claims: readonly Claim[]
readonly sources: readonly RenderSource[]
readonly evidenceHash: string
/** Cited sources absent from the cache (shown, never hidden). */
readonly missingSources?: readonly string[]
}
/** Escape text for HTML element content and double-quoted attributes. */
export function escapeHtml(text: string): string {
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}
const shortId = (sha: string) => sha.slice(0, 12)
/** Only these URLs become links: http(s) with no whitespace, quotes or angle brackets. */
export function safeUrl(url: string): string | undefined {
const trimmed = url.trim()
return /^https?:\/\/[^\s<>"']+$/i.test(trimmed) ? trimmed : undefined
}
const sortedClaims = (claims: readonly Claim[]) => [...claims].sort((a, b) => a.id.localeCompare(b.id))
const sortedSources = (sources: readonly RenderSource[]) =>
[...sources].sort((a, b) => a.meta.sha256.localeCompare(b.meta.sha256))
const statusCounts = (claims: readonly Claim[]) => {
const counts = new Map<string, number>()
for (const claim of claims) counts.set(claim.status, (counts.get(claim.status) ?? 0) + 1)
return [...counts].map(([status, count]) => `${count} ${status.toLowerCase()}`).join(", ")
}
const sealStatus = (meta: SourceMeta): "sealed" | "unsealed" => (meta.seal ? "sealed" : "unsealed")
const oneLine = (text: string) => text.replace(/\s+/g, " ").trim()
const mdCell = (text: string) => oneLine(text).replaceAll("|", "\\|").slice(0, 400)
/** Minimal inline Markdown for report bodies: code, bold, and http(s) links only. Input must already be escaped. */
function mdInline(escaped: string): string {
return escaped
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, text, url) => {
const safe = safeUrl(url)
return safe ? `<a href="${safe}" rel="noopener noreferrer">${text}</a>` : match
})
}
/** Minimal block Markdown for report bodies. Input must already be escaped. */
function mdBlocks(escaped: string): string {
const lines = escaped.split("\n")
const out: string[] = []
let list: string[] = []
const flush = () => {
if (list.length) out.push(`<ul>${list.map((item) => `<li>${mdInline(item)}</li>`).join("")}</ul>`)
list = []
}
for (const line of lines) {
const heading = /^(#{1,3})\s+(.*)$/.exec(line)
if (heading) {
flush()
const level = heading[1]!.length
out.push(`<h${level}>${mdInline(heading[2]!)}</h${level}>`)
continue
}
const bullet = /^\s*[-*]\s+(.*)$/.exec(line)
if (bullet) {
list.push(bullet[1]!)
continue
}
flush()
if (line.trim()) out.push(`<p>${mdInline(line)}</p>`)
}
flush()
return out.join("\n")
}
export function renderDossierMarkdown(input: DossierRenderInput): string {
const claims = sortedClaims(input.claims)
const sources = sortedSources(input.sources)
const missing = [...(input.missingSources ?? [])].sort((a, b) => a.localeCompare(b))
const lines = [
`# Research dossier: ${input.objective}`,
"",
`Evidence \`${input.evidenceHash}\` · ${claims.length} claim(s)${claims.length ? ` (${statusCounts(claims)})` : ""} · ${sources.length} source(s).`,
"",
"## Report",
"",
input.report.trim(),
"",
"## Claims",
"",
"| id | tag | status | claim | evidence |",
"| --- | --- | --- | --- | --- |",
]
for (const claim of claims) {
const evidence = claim.source
? `"${mdCell(claim.source.quote)}" — [${shortId(claim.source.sha256)}](#source-${shortId(claim.source.sha256)})`
: claim.parents?.length
? `inferred from ${claim.parents.map(shortId).join(", ")}`
: (claim.query ?? claim.reasoning ?? claim.falsification ?? "—")
lines.push(
`| ${shortId(claim.id)} | ${claim.tag} | ${claim.status} | ${mdCell(claim.statement)} | ${mdCell(evidence)} |`,
)
}
lines.push("", "## Sources", "")
for (const source of sources) {
const meta = source.meta
lines.push(
`<a id="source-${shortId(meta.sha256)}"></a>### \`${shortId(meta.sha256)}\` — ${meta.url}`,
"",
`- retrieved ${meta.retrieved} · provider ${meta.provider} · ${sealStatus(meta)}${meta.title ? ` · ${oneLine(meta.title)}` : ""}`,
"",
`> ${mdCell(source.text.slice(0, 300))}`,
"",
)
}
for (const sha of missing) lines.push(`- \`${shortId(sha)}\` (${sha}): cited but not in the cache.`, "")
return `${lines.join("\n").trim()}\n`
}
const CSS = [
"body{font-family:system-ui,sans-serif;max-width:72ch;margin:2rem auto;padding:0 1rem;color:#1a1a1a}",
".badge{display:inline-block;padding:0 .5em;border-radius:1em;font-size:.85em;font-weight:600}",
".tag-VERIFIED{background:#d9ead3}.tag-INFERRED{background:#fff2cc}.tag-HYPOTHESIS{background:#fce5cd}.tag-NEGATIVE_KNOWLEDGE{background:#d0e0e3}",
".status-LIVE{background:#d9ead3}.status-STALE{background:#f4cccc}.status-SUSPECT{background:#fce5cd}.status-REJECTED{background:#434343;color:#fff}",
"table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:.4em;text-align:left;vertical-align:top}",
"pre{background:#f4f4f4;padding:1em;overflow-x:auto;white-space:pre-wrap}",
"details{margin:.5em 0}summary{cursor:pointer;font-weight:600}",
].join("")
export function renderDossierHtml(input: DossierRenderInput): string {
const claims = sortedClaims(input.claims)
const sources = sortedSources(input.sources)
const missing = [...(input.missingSources ?? [])].sort((a, b) => a.localeCompare(b))
const sourceById = new Map(sources.map((source) => [source.meta.sha256, source]))
const claimRows = claims
.map((claim) => {
const evidence = claim.source
? (() => {
const source = sourceById.get(claim.source!.sha256)
const safe = source ? safeUrl(source.meta.url) : undefined
const link = safe
? `<a href="${escapeHtml(safe)}" rel="noopener noreferrer">${escapeHtml(shortId(claim.source!.sha256))}</a>`
: escapeHtml(shortId(claim.source!.sha256))
return `<details><summary>quote from ${link}</summary><blockquote>${escapeHtml(claim.source!.quote)}</blockquote></details>`
})()
: escapeHtml(
claim.parents?.length
? `inferred from ${claim.parents.map(shortId).join(", ")}`
: (claim.query ?? claim.reasoning ?? claim.falsification ?? "—"),
)
return [
"<tr>",
`<td><code>${escapeHtml(shortId(claim.id))}</code></td>`,
`<td><span class="badge tag-${claim.tag}">${claim.tag}</span></td>`,
`<td><span class="badge status-${claim.status}">${claim.status}</span></td>`,
`<td>${escapeHtml(claim.statement)}</td>`,
`<td>${evidence}</td>`,
"</tr>",
].join("")
})
.join("\n")
const sourceBlocks = sources
.map((source) => {
const meta = source.meta
return [
"<details>",
`<summary><code>${escapeHtml(shortId(meta.sha256))}</code> — ${escapeHtml(meta.url)}</summary>`,
"<ul>",
`<li>retrieved ${escapeHtml(meta.retrieved)} · provider ${escapeHtml(meta.provider)} · ${sealStatus(meta)}${meta.title ? ` · ${escapeHtml(meta.title)}` : ""}</li>`,
`<li>sha256 <code>${escapeHtml(meta.sha256)}</code></li>`,
"</ul>",
`<pre>${escapeHtml(source.text)}</pre>`,
"</details>",
].join("\n")
})
.join("\n")
return [
"<!doctype html>",
'<html lang="en">',
"<head>",
'<meta charset="utf-8">',
`<title>${escapeHtml(`Research dossier: ${input.objective}`)}</title>`,
`<style>${CSS}</style>`,
"</head>",
"<body>",
`<h1>${escapeHtml(`Research dossier: ${input.objective}`)}</h1>`,
`<p>Evidence <code>${escapeHtml(input.evidenceHash)}</code> · ${claims.length} claim(s)${claims.length ? ` (${escapeHtml(statusCounts(claims))})` : ""} · ${sources.length} source(s).</p>`,
"<h2>Report</h2>",
mdBlocks(escapeHtml(input.report)),
"<h2>Claims</h2>",
"<table><thead><tr><th>id</th><th>tag</th><th>status</th><th>claim</th><th>evidence</th></tr></thead><tbody>",
claimRows,
"</tbody></table>",
"<h2>Sources</h2>",
sourceBlocks,
missing
.map((sha) => `<p><code>${escapeHtml(shortId(sha))}</code> (${escapeHtml(sha)}): cited but not in the cache.</p>`)
.join("\n"),
"</body>",
"</html>",
"",
].join("\n")
}
/** Read the current run (factory or research mode) and render it. Missing pieces are readable errors. */
export async function renderResearchRun(
root: string,
options: { format: "md" | "html"; stateDir?: string },
): Promise<string> {
const layout = factoryLayout(root)
const report = await readFile(layout.researchReport, "utf8").catch(() => undefined)
if (report === undefined) throw new Error("No research report yet (.factory/research/REPORT.md).")
const dossier = await readDossier(layout.researchDossier).catch(() => undefined)
if (!dossier) throw new Error("No research dossier yet: es_research_complete writes .factory/research/dossier.json.")
const { claims } = applyDegradation(dossier.claims, await readRetractions(root))
const stateDir = options.stateDir ?? defaultStateDir()
const cache = researchCache(root, stateDir)
const key = await readEngineKey(stateDir).catch(() => undefined)
const shas = [...new Set(claims.flatMap((claim) => (claim.source ? [claim.source.sha256] : [])))].sort((a, b) =>
a.localeCompare(b),
)
const sources: RenderSource[] = []
const missing: string[] = []
for (const sha of shas) {
const cached = await cache.get(sha)
if (!cached) {
missing.push(sha)
continue
}
const meta: SourceMeta =
cached.meta.seal && key && verifyMetaSeal(cached.meta as unknown as Record<string, unknown>, key)
? cached.meta
: { ...cached.meta, seal: undefined }
sources.push({ meta, text: cached.text })
}
const input: DossierRenderInput = {
objective: dossier.subject,
report,
claims,
sources,
evidenceHash: dossier.evidenceHash,
...(missing.length ? { missingSources: missing } : {}),
}
return options.format === "html" ? renderDossierHtml(input) : renderDossierMarkdown(input)
}
+1
View File
@@ -51,6 +51,7 @@ async function claimFor(statement: string, tag: Tag, cache: SourceCache): Promis
quote: tag.quote,
...(source.meta.url ? { url: source.meta.url } : {}),
},
...(source.meta.tier ? { tier: source.meta.tier } : {}),
}
: {}),
})
+1 -1
View File
@@ -133,7 +133,7 @@ export function codeAuditTools(ops: OpsContext): EsToolDef[] {
findings: parsed.data,
at: new Date().toISOString(),
})
await writeAuditArtifacts(root, tree.audit, round, tree)
await writeAuditArtifacts(root, tree.audit, round, tree, ops.stateDir)
const recorded = await factory.auditVerdict(context.agent, tree.audit.id, verdict, String(notes)).catch(refusal)
await rewriteAuditReport(root, recorded.audit, round)
const status =
+5 -3
View File
@@ -5,8 +5,8 @@ import { normalizeClaim } from "../claims/claim.ts"
import { buildDossier, writeDossier } from "../claims/dossier.ts"
import { explainDossierRank, rankDossiers } from "../claims/rank.ts"
import { type Audit, describeTarget } from "../factory/state.ts"
import { factoryLayout } from "../layout.ts"
import { researchSourcesDir, SourceCache } from "../research/cache.ts"
import { stateDir as defaultStateDir, factoryLayout } from "../layout.ts"
import { researchCache } from "../research/ops.ts"
import type { Dossier } from "../schema/claims.ts"
import { type AuditFinding, type AuditRecord, AuditRecordSchema } from "../schema/codeaudit.ts"
import { atomicWrite, readJson, withLock, writeJson } from "../util/fs.ts"
@@ -47,6 +47,8 @@ export async function writeAuditArtifacts(
audit: Audit,
round: number,
tree: { root: string },
/** Engine-key dir for the sealed source cache (#98); defaults to the global state dir. */
stateDir?: string,
): Promise<{ dossier: Dossier; report: string }> {
const layout = factoryLayout(root)
const records = roundRecords(await readAuditRecords(root, audit.id), round)
@@ -73,7 +75,7 @@ export async function writeAuditArtifacts(
notes: record.notes,
})),
}),
{ cache: new SourceCache(researchSourcesDir(root)), root: tree.root },
{ cache: researchCache(root, stateDir ?? defaultStateDir()), root: tree.root },
)
const report = renderAuditReport(audit, round, records)
await atomicWrite(layout.auditReport(audit.id), report)
+56 -2
View File
@@ -3,8 +3,10 @@
// with a known answer: planted defects the audit pair must catch at the right
// lines, and license fixtures whose scout verdicts core must get right. The
// same graders run on scripted transcripts in CI and on real-model runs nightly.
import { readdir } from "node:fs/promises"
import { readAuditRecords } from "../codeaudit/store.ts"
import { readHarvestResult } from "../harvest/store.ts"
import { factoryLayout } from "../layout.ts"
import type { AuditRecord } from "../schema/codeaudit.ts"
import type { HarvestMatrix } from "../schema/harvest.ts"
import type { ScoutResult } from "../schema/scout.ts"
@@ -123,13 +125,65 @@ export function gradeHarvestFire(
export type FireSpec =
| { readonly kind: "audit"; readonly audit: string; readonly plants: readonly PlantedDefect[] }
| { readonly kind: "scout"; readonly expected: ReadonlyArray<{ name: string; verdict: string }> }
| { readonly kind: "harvest"; readonly expected: ReadonlyArray<{ candidate: string; verdict: string }> }
| {
readonly kind: "harvest"
readonly expected: ReadonlyArray<{ candidate: string; verdict: string }>
/** Which harvest run to grade (the harvester completes run "harvest" in evals). */
readonly run?: string
}
/** Harvest run ids are path segments: kebab-case, like the brainstorm runs. */
const RUN_ID = /^[a-z0-9][a-z0-9-]{0,39}$/
/**
* Every harvest run with a completed result on disk. Harvest went
* run-scoped in #109, so the default run is not the only place a result can
* land; the legacy top-level files still read as the default run.
*/
async function completedHarvestRuns(root: string): Promise<string[]> {
const done: string[] = []
if (await readHarvestResult(root).catch(() => undefined)) done.push("default")
const entries = await readdir(factoryLayout(root).harvestRuns, { withFileTypes: true }).catch(() => [])
for (const entry of entries) {
if (!entry.isDirectory() || !RUN_ID.test(entry.name) || entry.name === "default") continue
if (await readHarvestResult(root, entry.name).catch(() => undefined)) done.push(entry.name)
}
return done.sort((a, b) => a.localeCompare(b, "en"))
}
/** Grade a fire from what the seats left in the workspace (records, scout result, harvest result). */
export async function gradeFireCase(spec: FireSpec, root: string): Promise<FireGrade> {
if (spec.kind === "audit") return gradeAuditFire(await readAuditRecords(root, spec.audit), spec.plants)
if (spec.kind === "harvest") {
const result = await readHarvestResult(root).catch(() => undefined)
if (spec.run !== undefined) {
if (!RUN_ID.test(spec.run))
return {
pass: false,
caught: [],
failures: [`invalid harvest run id "${spec.run}": use lowercase letters, digits and dashes`],
}
const named = await readHarvestResult(root, spec.run).catch(() => undefined)
if (!named)
return {
pass: false,
caught: [],
failures: [`run "${spec.run}" never completed (no .factory/harvest/runs/${spec.run}/harvest.json)`],
}
return gradeHarvestFire(named.matrix, spec.expected)
}
// No named run: grade the run the case actually produced. Exactly one
// completed run grades; zero or several fail explicitly — never a silent
// default that grades the wrong run.
const done = await completedHarvestRuns(root)
if (done.length === 0)
return { pass: false, caught: [], failures: ["the harvest never completed (no .factory/harvest/harvest.json)"] }
if (done.length > 1)
return {
pass: false,
caught: [],
failures: [`several harvest runs completed (${done.join(", ")}): set the fire's run`],
}
const result = await readHarvestResult(root, done[0]!).catch(() => undefined)
if (!result)
return { pass: false, caught: [], failures: ["the harvest never completed (no .factory/harvest/harvest.json)"] }
return gradeHarvestFire(result.matrix, spec.expected)
+8
View File
@@ -45,3 +45,11 @@ export async function startServeFixtures(dir: string): Promise<ServeFixtures> {
/** Substitute the fixture server URL into a case prompt (no-op without one). */
export const renderEvalPrompt = (prompt: string, serveUrl: string): string =>
prompt.replaceAll("{{SERVE_URL}}", serveUrl)
/**
* Whether a case fetches from the loopback fixture server. Only those cases'
* seat environment gets ES_RESEARCH_ALLOW_LOOPBACK=1 (the fetch SSRF guard
* refuses loopback by default); every other case runs with the caller's
* environment untouched, never a global allowance.
*/
export const usesFixtureServer = (prompt: string): boolean => prompt.includes("{{SERVE_URL}}")
+1
View File
@@ -2,6 +2,7 @@ export * from "./journal.ts"
export * from "./liveness.ts"
export * from "./machine.ts"
export * from "./pending.ts"
export * from "./preset.ts"
export * from "./runs.ts"
export * from "./seats.ts"
export * from "./state.ts"
+9 -7
View File
@@ -162,11 +162,13 @@ const STAGE_WORD: Record<string, string> = {
}
const stageWork = (state: FactoryState) =>
state.stage === "BUILD"
? `Building ${state.activePhase ?? "the next phase"}`
: state.stage === "QA"
? `QA on ${state.activePhase ?? "the active phase"}`
: (STAGE_WORD[state.stage] ?? state.stage)
state.mode === "research" && state.stage === "RESEARCH"
? "Research run"
: state.stage === "BUILD"
? `Building ${state.activePhase ?? "the next phase"}`
: state.stage === "QA"
? `QA on ${state.activePhase ?? "the active phase"}`
: (STAGE_WORD[state.stage] ?? state.stage)
const shortSession = (sessionID: string) => (sessionID.length > 16 ? sessionID.slice(0, 16) : sessionID)
@@ -188,7 +190,7 @@ export function headline(state: FactoryState | undefined, liveness: Liveness): s
: "Done: the run is complete."
if (liveness.pending.length) {
const stages = liveness.pending.map((item) => item.stage)
return `Waiting for you: approve the ${stages.join(" and ")} (\`es approve ${stages[0]}\` in a terminal; /es-approve previews it).`
return `Waiting for you: approve the ${stages.join(" and ")} with /es-approve (or \`es approve ${stages[0]}\` in a terminal).`
}
if (state.stage === "GRILL") return "The grill is running: answer its questions in the grill session."
const running = liveness.seats.filter((seat) => seat.state === "running")
@@ -300,7 +302,7 @@ export function formatHuman(state: FactoryState | undefined, liveness: Liveness,
}
if (liveness.pending.length)
lines.push(
`waiting on you: ${liveness.pending.map((item) => `approve ${item.stage} (\`es approve ${item.stage}\`)`).join(", ")}`,
`waiting on you: ${liveness.pending.map((item) => `approve ${item.stage} (/es-approve, or \`es approve ${item.stage}\`)`).join(", ")}`,
)
return lines.join("\n")
}
+84 -14
View File
@@ -42,6 +42,7 @@ import { sha256 } from "../util/hash.ts"
import { run, splitCommand } from "../util/proc.ts"
import * as Git from "../worktree/git.ts"
import { once } from "./journal.ts"
import { readPresetSeed, SELF_DOGFOOD_BASE_BRANCH, SELF_DOGFOOD_PRESET } from "./preset.ts"
import { indexRun } from "./runs.ts"
import {
type Audit,
@@ -61,6 +62,7 @@ import { deferredFacts, diffFrontier, readFrontier, reopenedNodes, treeCounts }
/** The operation was refused; state is unchanged. */
export class FactoryError extends Error {}
/** The factory is halted; only a human can resume it. */
export class FactoryHalted extends FactoryError {}
@@ -104,6 +106,8 @@ export interface PrRequest {
readonly base: string
readonly title: string
readonly body: string
/** Preset that seeded the run, if any: the opener pins the base for preset runs. */
readonly preset?: string
}
export interface PrOpener {
@@ -239,6 +243,7 @@ export class Factory {
version: FACTORY_STATE_VERSION,
runId: newRunId(this.now()),
stage: "GRILL",
mode: "build",
createdAt: at,
updatedAt: at,
spend: { usd: 0, estimated: false, events: 0 },
@@ -355,6 +360,10 @@ export class Factory {
async (state) => {
if (state.stage !== "GRILL" || state.phases.length)
throw new FactoryError(`A run already exists (${state.runId}, ${state.stage}).`)
// A preset-seeded run carries its seed into sealed run state, so the
// base-branch pin below survives the grill rewriting the roadmap.
const seed = await readPresetSeed(this.root).catch(() => undefined)
if (seed) state.preset = seed.preset
await this.audit(actor, "factory.begin", { runId: state.runId })
return state
},
@@ -431,13 +440,54 @@ export class Factory {
})
}
/**
* Start a research-only run (#110, Option A): no frontier, no approvals, no
* git repo needed. The run enters RESEARCH directly with an objective and a
* mandatory spend ceiling, and completes at DONE. Spend tracking, STOP,
* halts, liveness and the run index are the factory machinery, unchanged.
* Setting the ceiling is human-only: the CLI and the headless verb pass
* `--max-usd`, never an agent.
*/
async beginResearchRun(input: { objective: string; ceilingUSD: number }, actor = "system"): Promise<FactoryState> {
const objective = String(input.objective ?? "").trim()
if (!objective) throw new FactoryError("A research run needs an objective: what question is it answering?")
if (!Number.isFinite(input.ceilingUSD) || !(input.ceilingUSD > 0))
throw new FactoryError(
"A research run needs a spend ceiling in USD (--max-usd <USD>); seats halt when the spend reaches it.",
)
return this.mutate(
actor,
async (state) => {
if (await exists(this.layout.state))
throw new FactoryError(`A run already exists (${state.runId}, ${state.stage}).`)
state.mode = "research"
state.objective = objective
state.spendCeilingUSD = input.ceilingUSD
state.stage = "RESEARCH"
await this.audit(actor, "research.begin", {
runId: state.runId,
objective,
spendCeilingUSD: state.spendCeilingUSD,
})
return state
},
{ create: true },
)
}
// ------------------------------------------------------------ research → spec
async completeResearch(agentId: string | undefined): Promise<FactoryState> {
this.requireSeat(agentId, "factory")
// Build runs complete through the factory; research runs complete through
// the deep-researcher coordinator (#111). The factory seat stays admitted
// in research mode for tests and human-driven completion.
this.requireSeat(agentId, "factory", "deep-researcher")
return this.mutate(`agent:${agentId}`, async (state) => {
this.requireStage(state, "RESEARCH")
this.requireAuditsClear(state)
if (state.mode !== "research" && seatOf(agentId) !== "factory")
throw new FactoryError("Only the factory seat may complete a build run.")
const researchMode = state.mode === "research"
const report = await readFile(this.layout.researchReport, "utf8").catch(() => undefined)
if (report === undefined)
throw new FactoryError("Research is not done: .factory/research/REPORT.md does not exist.")
@@ -454,18 +504,21 @@ export class Factory {
)
// Facts the grill could not settle from the repo are research's to answer:
// each deferred fact node must be cited on a (grounded) claim line.
// Research runs have no frontier, so there is nothing to require.
const frontier = await readFrontier(this.root).catch(() => undefined)
const unanswered = (frontier ? deferredFacts(frontier) : []).filter(
(fact) => !audit.claims.some((claim) => claim.text.includes(`(fact:${fact.id})`)),
)
if (unanswered.length)
throw new FactoryError(
`Research must answer every fact the grill deferred to it. Missing: ${unanswered
.map((fact) => `${fact.id} — ${fact.question}`)
.join(
"; ",
)}. Answer each on a tagged claim line in REPORT.md marked (fact:<id>), e.g. "- Postgres 16 is supported (fact:db) [VERIFIED: …]".`,
if (!researchMode) {
const unanswered = (frontier ? deferredFacts(frontier) : []).filter(
(fact) => !audit.claims.some((claim) => claim.text.includes(`(fact:${fact.id})`)),
)
if (unanswered.length)
throw new FactoryError(
`Research must answer every fact the grill deferred to it. Missing: ${unanswered
.map((fact) => `${fact.id} — ${fact.question}`)
.join(
"; ",
)}. Answer each on a tagged claim line in REPORT.md marked (fact:<id>), e.g. "- Postgres 16 is supported (fact:db) [VERIFIED: …]".`,
)
}
// The report's claims become the research dossier (witnessed again on
// write). With a domain pack active, its constitution vets every claim
// and gates the report by the pack's accept threshold; no pack leaves
@@ -500,7 +553,7 @@ export class Factory {
this.layout.researchDossier,
buildDossier({
mode: "research",
subject: frontier?.idea ?? "research",
subject: frontier?.idea ?? state.objective ?? "research",
claims,
now: this.now(),
}),
@@ -516,8 +569,9 @@ export class Factory {
...store.all().flatMap((claim) => (claim.source ? [claim.source.sha256] : [])),
])
const removed = await cache.prune(cited)
state.stage = "SPEC"
await this.audit(`agent:${agentId}`, "stage.spec", {
// A research run ends here; a build run moves on to SPEC.
state.stage = researchMode ? "DONE" : "SPEC"
await this.audit(`agent:${agentId}`, researchMode ? "stage.done" : "stage.spec", {
reportHash: sha256(report),
coverage: audit.coverage,
prunedSources: removed.length,
@@ -542,6 +596,14 @@ export class Factory {
const roadmap = RoadmapSchema.parse(JSON.parse(await readFile(this.layout.roadmap, "utf8")))
if (roadmap.phases.length > this.limits.maxPhases)
throw new FactoryError(`Roadmap exceeds ${this.limits.maxPhases} phases.`)
// A preset-seeded run pins its release base: the grill must have kept
// baseBranch when it rewrote the roadmap, otherwise the run would
// silently release onto the wrong branch (falling back to the checkout
// or "main"). Refuse to advance instead.
if (state.preset === SELF_DOGFOOD_PRESET && roadmap.baseBranch !== SELF_DOGFOOD_BASE_BRANCH)
throw new FactoryError(
`Preset "${SELF_DOGFOOD_PRESET}" requires roadmap.baseBranch "${SELF_DOGFOOD_BASE_BRANCH}" (the grill must keep it when rewriting the roadmap); found ${roadmap.baseBranch === undefined ? "no baseBranch" : `"${roadmap.baseBranch}"`}. Restore it, then start the build again.`,
)
const hashes = new Map(check.record.subject.map((item) => [item.path, item.sha256]))
state.phases = roadmap.phases.map((phase) => ({
id: phase.id,
@@ -1139,6 +1201,13 @@ export class Factory {
throw new FactoryError(
`Gates are green on ${runBranch}, but no PR opener is available. Push the branch, open a PR to ${state.baseBranch}, then run \`es factory pr <url>\`.`,
)
// Defence in depth for preset runs: the base was pinned at startBuild,
// and the opener pins it again — never release a preset run elsewhere.
const preset = state.preset
if (preset === SELF_DOGFOOD_PRESET && state.baseBranch !== SELF_DOGFOOD_BASE_BRANCH)
throw new FactoryError(
`Preset "${SELF_DOGFOOD_PRESET}" releases onto "${SELF_DOGFOOD_BASE_BRANCH}", not "${state.baseBranch}".`,
)
const body = await this.prBody(state)
const opened = await once(this.root, `${state.runId}:pr`, () =>
pr.open({
@@ -1150,6 +1219,7 @@ export class Factory {
.join("; ")
.slice(0, 200)}`,
body,
...(preset !== undefined ? { preset } : {}),
}),
)
return this.mutate(`agent:${agentId}`, async (current) => {
+170
View File
@@ -0,0 +1,170 @@
// Factory presets (#137): named starting points for runs. The first preset
// is `self-dogfood`, where the factory builds this repository — one issue in
// an isolated worktree under the real merge gate, released as a draft PR a
// human reviews. `initFromPreset` is human-only at the CLI (it writes factory
// control files no agent may touch); the preset files themselves are read-only
// assets validated against their schemas here.
import { readFile } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { appendAuditEntry } from "../audit/chain.ts"
import { PROJECT_FORBIDDEN_KEYS } from "../config.ts"
import { factoryLayout } from "../layout.ts"
import { EsConfigSchema } from "../schema/config.ts"
import { GatesConfigSchema } from "../schema/gates.ts"
import { RoadmapSchema } from "../schema/roadmap.ts"
import { rebaseline } from "../trust/control.ts"
import { atomicWrite, readJson, withLock, writeJson } from "../util/fs.ts"
const PRESETS = fileURLToPath(new URL("../../assets/presets/", import.meta.url))
/** The only preset so far: the factory builds this repo. */
export const SELF_DOGFOOD_PRESET = "self-dogfood" as const
/** Self-dogfood release PRs target `rewrite`, never `main`. */
export const SELF_DOGFOOD_BASE_BRANCH = "rewrite" as const
/** Default spend ceiling (USD) the runbook sets on the grill frontier. */
export const SELF_DOGFOOD_SPEND_CEILING_USD = 15 as const
export const KNOWN_PRESETS = [SELF_DOGFOOD_PRESET] as const
export type PresetName = (typeof KNOWN_PRESETS)[number]
export const presetsDir = () => PRESETS
export interface PresetFiles {
readonly config: string
readonly gates: string
}
/** Read and schema-validate a preset's control files (throws on unknown presets or invalid files). */
export async function readPreset(name: string): Promise<PresetFiles> {
if (!(KNOWN_PRESETS as readonly string[]).includes(name)) throw new Error(`unknown preset "${name}"`)
const dir = path.join(PRESETS, name)
const [config, gates] = await Promise.all([
readFile(path.join(dir, "config.json"), "utf8").catch(() => {
throw new Error(`preset "${name}" is missing config.json`)
}),
readFile(path.join(dir, "gates.json"), "utf8").catch(() => {
throw new Error(`preset "${name}" is missing gates.json`)
}),
])
const configParsed = EsConfigSchema.safeParse(JSON.parse(config))
if (!configParsed.success)
throw new Error(`preset "${name}" config.json is invalid: ${configParsed.error.issues[0]?.message}`)
const forbidden = PROJECT_FORBIDDEN_KEYS.filter(
(key: string) => JSON.parse(config) && hasPath(JSON.parse(config), key),
)
if (forbidden.length)
throw new Error(`preset "${name}" config.json sets project-forbidden keys: ${forbidden.join(", ")}`)
const gatesParsed = GatesConfigSchema.safeParse(JSON.parse(gates))
if (!gatesParsed.success)
throw new Error(`preset "${name}" gates.json is invalid: ${gatesParsed.error.issues[0]?.message}`)
return { config, gates }
}
const hasPath = (layer: Record<string, unknown>, dotted: string): boolean => {
let node: unknown = layer
for (const segment of dotted.split(".")) {
if (typeof node !== "object" || node === null || !(segment in node)) return false
node = (node as Record<string, unknown>)[segment]
}
return true
}
export interface PresetIdea {
/** GitHub issue number the run builds. */
readonly issue: number
readonly title: string
readonly body: string
}
/**
* Seed `.factory/` from a preset for a human-driven run: control files, a
* roadmap seed pinning the release base branch, and the issue as the idea
* note. The grill still owns the roadmap and frontier from here — it must
* keep `baseBranch` when it rewrites the roadmap.
*
* Every write is atomic and lock-protected (`withLock` + `writeJson` /
* `atomicWrite`), as the `.factory/` runtime invariant requires. Afterwards
* the control baseline is re-recorded and the seeding is audit-logged, so a
* project with a prior baseline reports no drift. `actor` is the human
* caller (the CLI threads `human:<user>` through).
*/
export async function initFromPreset(
root: string,
preset: string,
idea: PresetIdea,
options: { actor?: string } = {},
): Promise<{ written: string[]; baseBranch: string }> {
const actor = options.actor ?? "human:unknown"
const files = await readPreset(preset)
const roadmap = RoadmapSchema.parse({
version: 1,
title: `Self-dogfood #${idea.issue}: ${idea.title}`,
baseBranch: SELF_DOGFOOD_BASE_BRANCH,
phases: [{ id: "dogfood", title: `Build issue #${idea.issue}` }],
})
const layout = factoryLayout(root)
const noteRel = `.factory/notes/idea-${idea.issue}.md`
const noteFile = path.join(root, noteRel)
await Promise.all([
withLock(layout.config, () => writeJson(layout.config, JSON.parse(files.config))),
withLock(layout.gates, () => writeJson(layout.gates, JSON.parse(files.gates))),
withLock(layout.roadmap, () => writeJson(layout.roadmap, roadmap)),
withLock(noteFile, () => atomicWrite(noteFile, `# #${idea.issue}: ${idea.title}\n\n${idea.body.trim()}\n`)),
writePresetSeed(root, { preset, issue: idea.issue, baseBranch: SELF_DOGFOOD_BASE_BRANCH }),
])
await rebaseline(root, `${actor} (es factory init --preset ${preset})`)
await appendAuditEntry(root, { actor, action: "factory.preset", payload: { preset, issue: idea.issue } })
return {
written: [".factory/config.json", ".factory/gates.json", ".factory/roadmap.json", noteRel],
baseBranch: SELF_DOGFOOD_BASE_BRANCH,
}
}
/** Which preset seeded a run: written at init, consumed into sealed run state at begin. */
export interface PresetSeed {
readonly preset: string
readonly issue: number
readonly baseBranch: string
}
const seedFile = (root: string) => path.join(factoryLayout(root).runtime, "preset.json")
/** Record the seeding preset (atomic, lock-protected; runtime state is agent deny-write). */
export const writePresetSeed = (root: string, seed: PresetSeed): Promise<void> =>
withLock(seedFile(root), () => writeJson(seedFile(root), seed))
/** The seeding preset, if this project was started with `es factory init --preset`. */
export const readPresetSeed = (root: string): Promise<PresetSeed | undefined> => readJson<PresetSeed>(seedFile(root))
/**
* The single argv builder every PR opener uses for `gh pr create --draft`.
* For a preset-seeded run the base is pinned: `--base main` (or anything
* other than the preset's base) is refused here, before anything is pushed.
*/
export function buildPrArgs(options: {
branch: string
base: string
title: string
body: string
preset?: string
}): string[] {
if (options.preset === SELF_DOGFOOD_PRESET && options.base !== SELF_DOGFOOD_BASE_BRANCH)
throw new Error(
`refusing --base "${options.base}" for a preset "${SELF_DOGFOOD_PRESET}" run (release PRs target "${SELF_DOGFOOD_BASE_BRANCH}", never "main")`,
)
return [
"gh",
"pr",
"create",
"--draft",
"--head",
options.branch,
"--base",
options.base,
"--title",
options.title,
"--body",
options.body,
]
}
+6
View File
@@ -79,6 +79,10 @@ export const FactoryStateSchema = z.object({
version: z.literal(FACTORY_STATE_VERSION),
runId: z.string(),
stage: StageSchema,
/** Build runs follow the software flow; research runs answer one objective and end at DONE (#110). */
mode: z.enum(["build", "research"]).default("build"),
/** The question a research run answers (also the dossier subject when there is no frontier). */
objective: z.string().optional(),
createdAt: z.string(),
updatedAt: z.string(),
spendCeilingUSD: z.number().positive().optional(),
@@ -90,6 +94,8 @@ export const FactoryStateSchema = z.object({
}),
baseBranch: z.string().optional(),
runBranch: z.string().optional(),
/** Preset that seeded the run (`es factory init --preset`): preset runs pin their release base (#137). */
preset: z.string().min(1).optional(),
phases: z.array(PhaseRuntimeSchema).default([]),
activePhase: z.string().optional(),
/** Audits not tied to a phase (a path in the project). */
+1
View File
@@ -23,6 +23,7 @@ const DEPTH = ["", "a brief", "thesis + antithesis", "thesis + antithesis, then
export function factorySummary(state: FactoryState | undefined, extras: SummaryExtras = {}): string {
if (!state) return "<factory-state>No factory run in this project. Start one with /grill.</factory-state>"
const lines = [`run ${state.runId} · stage ${state.stage}`]
if (state.mode === "research" && state.objective) lines.push(`research run objective: ${state.objective}`)
if (extras.frontier) lines.push(treeLine(extras.frontier))
if (state.stage === "RESEARCH" && extras.researchDepth !== undefined)
lines.push(`research depth ${extras.researchDepth}: ${DEPTH[extras.researchDepth] ?? "custom"}`)
+3 -2
View File
@@ -1,5 +1,5 @@
// Waivers are requested by agents but granted only by a human through the
// CLI (TTY) or TUI dialog. Records are HMAC-signed like approvals; unsigned,
// CLI (TTY) or TUI dialog. Records are Ed25519-signed like approvals; unsigned,
// malformed or expired waivers are ignored and reported.
import { readdir, readFile } from "node:fs/promises"
import { hostname, userInfo } from "node:os"
@@ -7,6 +7,7 @@ import path from "node:path"
import { type HumanSigner, signatureProblem } from "../approval/keystore.ts"
import { appendAuditEntry, auditHead } from "../audit/chain.ts"
import { factoryLayout, stateDir } from "../layout.ts"
import type { Channel } from "../schema/approval.ts"
import type { GateFinding } from "../schema/gates.ts"
import { isWaiverActive, type Waiver, WaiverSchema } from "../schema/waiver.ts"
import { rebaseline } from "../trust/control.ts"
@@ -19,7 +20,7 @@ export interface RecordWaiverInput {
readonly files?: string
readonly reason: string
readonly expiresAt: Date
readonly channel: "cli" | "tui"
readonly channel: Channel
readonly approvedBy?: string
/** The unlocked human key. */
readonly signer: HumanSigner
+158 -22
View File
@@ -4,12 +4,15 @@
// (bounded candidate content), matrix (policy-enforced verdicts), complete
// (report, vendor plan, clean-room specs) and prior-art (related projects for
// brainstorm ideas).
import path from "node:path"
import { seatOf } from "../agents/registry.ts"
import { words } from "../brainstorm/engine.ts"
import { parseRunId } from "../brainstorm/store.ts"
import { factoryLayout } from "../layout.ts"
import type { EsToolDef } from "../ops/tools.ts"
import { ToolRefusal } from "../ops/tools.ts"
import { DEFAULT_LICENSE_WHITELIST, type HarvestProfile, type HarvestRow, HarvestRowSchema } from "../schema/harvest.ts"
import { canonicalPath } from "../trust/paths.ts"
import { buildMatrix, checkVerdict, recheckMatrix, renderHarvest } from "./matrix.ts"
import { type PriorArtSearch, recordPriorArt } from "./prior-art.ts"
import {
@@ -51,6 +54,25 @@ const object = (properties: Record<string, unknown>, required: string[] = []) =>
additionalProperties: false,
})
const runOf = (input: { run?: unknown }): string => {
try {
return parseRunId(input.run)
} catch (error) {
throw new ToolRefusal(error instanceof Error ? error.message : String(error))
}
}
/** Seats that may run a deterministic license-and-verdict scan without a harvester (#109). */
const mayTarget = (agent: string | undefined): boolean => {
const seat = seatOf(agent)
return seat === "grill" || seat === "factory" || seat === "scout" || seat === "harvester"
}
/** Seats that may search prior art for a brainstorm (#109): the harvester plus every callable-brainstorm caller. */
const maySearchPriorArt = (agent: string | undefined): boolean => {
const seat = seatOf(agent)
return seat === "harvester" || seat === "brainstormer" || seat === "grill" || seat === "factory"
}
export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
const { root } = context
// Agents never scan outside the project or into the private state dir.
@@ -68,6 +90,10 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
input: object(
{
objective: { type: "string", description: "What are we harvesting, and for what?" },
run: {
type: "string",
description: "Plan run id (lowercase/dashes; the human /harvest is the default run).",
},
candidates: {
type: "array",
description:
@@ -90,11 +116,12 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
execute: async (input, toolContext) => {
if (seatOf(toolContext.agent) !== "harvester")
throw new ToolRefusal("Only the harvester seat may plan a darkharvest.")
const run = runOf(input)
const objective = String(input.objective ?? "").trim()
if (!objective) throw new ToolRefusal("Give the teardown an objective.")
const candidates = Array.isArray(input.candidates) ? input.candidates : []
if (!candidates.length) throw new ToolRefusal("Give at least one candidate.")
const existing = await readHarvestPlan(root).catch((error: Error) => {
const existing = await readHarvestPlan(root, run).catch((error: Error) => {
throw new ToolRefusal(`Cannot read the current plan: ${error.message}`)
})
if (existing && input.force !== true)
@@ -130,9 +157,9 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
whitelist,
createdAt: new Date().toISOString(),
}
await startHarvestRun(root, plan)
await startHarvestRun(root, plan, run)
return [
`Darkharvest planned: ${objective}`,
`Darkharvest planned (run "${run}"): ${objective}`,
`Candidates (${planned.length}): ${planned.map((item) => `${item.id} ← ${item.source}`).join(", ")}`,
`Read budget: ${plan.readTokensPerCandidate} tokens per candidate · whitelist: ${whitelist.join(", ") || "(empty: nothing may be depended on or vendored)"}`,
...(ignored.length
@@ -140,7 +167,7 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
: []),
"",
"Next steps:",
"1. es_harvest_scan each candidate (profiles land under .factory/harvest/<id>/).",
"1. es_harvest_scan each candidate (profiles land under the run's directory).",
"2. Read what you need with es_harvest_read (the budget is enforced).",
"3. Propose the feature matrix with es_harvest_matrix; the license policy rewrites any depend/vendor it cannot authorize.",
"4. es_harvest_complete writes HARVEST.md, VENDOR-PLAN.md and clean-room specs.",
@@ -176,12 +203,19 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
{
name: "es_harvest_scan",
description:
"Harvester only: clone or read a planned candidate, index it and detect its license (fail-closed). Writes .factory/harvest/<id>/profile.json.",
input: object({ candidate: { type: "string", description: "Candidate id from the plan." } }, ["candidate"]),
"Harvester only: clone or read a planned candidate, index it and detect its license (fail-closed). Writes the run's <id>/profile.json.",
input: object(
{
candidate: { type: "string", description: "Candidate id from the plan." },
run: { type: "string", description: "Plan run id from es_harvest_plan." },
},
["candidate"],
),
execute: async (input, toolContext) => {
if (seatOf(toolContext.agent) !== "harvester")
throw new ToolRefusal("Only the harvester seat may scan candidates.")
const plan = await readHarvestPlan(root).catch((error: Error) => {
const run = runOf(input)
const plan = await readHarvestPlan(root, run).catch((error: Error) => {
throw new ToolRefusal(`Cannot read the plan: ${error.message}`)
})
if (!plan) throw new ToolRefusal("No darkharvest plan. Call es_harvest_plan first.")
@@ -203,7 +237,7 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
} catch (error) {
throw new ToolRefusal(`Scan of "${id}" failed: ${error instanceof Error ? error.message : String(error)}`)
}
await writeProfile(root, profile)
await writeProfile(root, profile, run)
const policy = checkVerdict("depend", profile.license, plan.whitelist)
return [
`Scanned ${id}: ${profile.name}`,
@@ -225,17 +259,19 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
file: { type: "string" },
offset: { type: "number" },
maxChars: { type: "number" },
run: { type: "string", description: "Plan run id from es_harvest_plan." },
},
["candidate", "file"],
),
execute: async (input, toolContext) => {
if (seatOf(toolContext.agent) !== "harvester")
throw new ToolRefusal("Only the harvester seat may read candidates.")
const plan = await readHarvestPlan(root).catch((error: Error) => {
const run = runOf(input)
const plan = await readHarvestPlan(root, run).catch((error: Error) => {
throw new ToolRefusal(`Cannot read the plan: ${error.message}`)
})
if (!plan) throw new ToolRefusal("No darkharvest plan.")
if (!(await readProfile(root, String(input.candidate))))
if (!(await readProfile(root, String(input.candidate), run)))
throw new ToolRefusal(`Scan "${input.candidate}" first.`)
const read = await readCandidate(root, String(input.candidate), String(input.file), {
...(input.offset !== undefined ? { offset: Number(input.offset) } : {}),
@@ -257,6 +293,7 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
"Harvester only: submit the feature matrix. The license policy rewrites depend/vendor that cannot be authorized and attaches attribution.",
input: object(
{
run: { type: "string", description: "Plan run id from es_harvest_plan." },
rows: {
type: "array",
items: object(
@@ -284,7 +321,8 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
execute: async (input, toolContext) => {
if (seatOf(toolContext.agent) !== "harvester")
throw new ToolRefusal("Only the harvester seat may build the matrix.")
const plan = await readHarvestPlan(root).catch((error: Error) => {
const run = runOf(input)
const plan = await readHarvestPlan(root, run).catch((error: Error) => {
throw new ToolRefusal(`Cannot read the plan: ${error.message}`)
})
if (!plan) throw new ToolRefusal("No darkharvest plan.")
@@ -296,7 +334,7 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
"Rows must be { feature, cells: [{ candidate, verdict: depend|vendor|clean-room|skip }] }.",
)
}
const profiles = await withCurrentLicenses(root, await readProfiles(root))
const profiles = await withCurrentLicenses(root, await readProfiles(root, run))
const missing = plan.candidates.filter((candidate) => !profiles.has(candidate.id))
if (missing.length)
throw new ToolRefusal(`Scan these candidates first: ${missing.map((item) => item.id).join(", ")}.`)
@@ -306,7 +344,7 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
rows,
plan.whitelist,
)
await writeMatrix(root, built.matrix)
await writeMatrix(root, built.matrix, run)
const counts = new Map<string, number>()
for (const row of built.matrix.rows)
for (const cell of row.cells) counts.set(cell.verdict, (counts.get(cell.verdict) ?? 0) + 1)
@@ -330,22 +368,24 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
description: "Harvester only: finalise the teardown; writes HARVEST.md, VENDOR-PLAN.md and clean-room specs.",
input: object({
allowPartial: { type: "boolean", description: "Finish with unscanned candidates recorded as gaps." },
run: { type: "string", description: "Plan run id from es_harvest_plan." },
}),
execute: async (input, toolContext) => {
if (seatOf(toolContext.agent) !== "harvester")
throw new ToolRefusal("Only the harvester seat may complete a darkharvest.")
const plan = await readHarvestPlan(root).catch((error: Error) => {
const run = runOf(input)
const plan = await readHarvestPlan(root, run).catch((error: Error) => {
throw new ToolRefusal(`Cannot read the plan: ${error.message}`)
})
if (!plan) throw new ToolRefusal("No darkharvest plan.")
const profiles = await readProfiles(root)
const profiles = await readProfiles(root, run)
const scanned = new Set(profiles.map((profile) => profile.id))
const missing = plan.candidates.filter((candidate) => !scanned.has(candidate.id))
if (missing.length && input.allowPartial !== true)
throw new ToolRefusal(
`Candidates not scanned: ${missing.map((item) => item.id).join(", ")}. Scan them, or pass allowPartial:true to record them as gaps.`,
)
const stored = await readMatrix(root).catch((error: Error) => {
const stored = await readMatrix(root, run).catch((error: Error) => {
throw new ToolRefusal(`Cannot read the matrix: ${error.message}`)
})
if (!stored) throw new ToolRefusal("No matrix yet. Build it with es_harvest_matrix first.")
@@ -354,10 +394,10 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
const current = await withCurrentLicenses(root, profiles)
const rechecked = recheckMatrix(stored, current, plan.whitelist)
const matrix = rechecked.matrix
if (rechecked.downgrades.length) await writeMatrix(root, matrix)
if (rechecked.downgrades.length) await writeMatrix(root, matrix, run)
const finalProfiles = [...current.values()]
const rendered = renderHarvest(plan.objective, finalProfiles, matrix)
await writeHarvestResult(root, plan.objective, finalProfiles, matrix, rendered)
await writeHarvestResult(root, plan.objective, finalProfiles, matrix, rendered, run)
const counts = new Map<string, number>()
for (const row of matrix.rows)
for (const cell of row.cells) counts.set(cell.verdict, (counts.get(cell.verdict) ?? 0) + 1)
@@ -371,16 +411,17 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
]
: []),
`Verdicts: ${[...counts].map(([verdict, count]) => `${verdict} ${count}`).join(", ")}`,
`Written: ${base}/harvest/harvest.json, HARVEST.md, VENDOR-PLAN.md${rendered.cleanRoom.length ? ` and ${rendered.cleanRoom.length} clean-room spec(s)` : ""}`,
`Written: ${base}/harvest/runs/${run}/harvest.json, HARVEST.md, VENDOR-PLAN.md${rendered.cleanRoom.length ? ` and ${rendered.cleanRoom.length} clean-room spec(s)` : ""}`,
].join("\n")
},
},
{
name: "es_harvest_prior_art",
description:
"Harvester only: find projects related to brainstorm ideas. You decide the relation (novel/similar/existing) and pass the result to es_brainstorm_complete.",
"Harvester, brainstormer, grill or factory: find projects related to brainstorm ideas. You decide the relation (novel/similar/existing) and pass the result to es_brainstorm_complete.",
input: object(
{
run: { type: "string", description: "Brainstorm run these searches belong to (keyed per run)." },
ideas: {
type: "array",
items: object({ id: { type: "string" }, title: { type: "string" }, text: { type: "string" } }, [
@@ -393,8 +434,9 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
["ideas"],
),
execute: async (input, toolContext) => {
if (seatOf(toolContext.agent) !== "harvester")
throw new ToolRefusal("Only the harvester seat may run prior-art checks.")
if (!maySearchPriorArt(toolContext.agent))
throw new ToolRefusal("Only the harvester, brainstormer, grill and factory seats may run prior-art checks.")
const run = runOf(input)
const ideas = Array.isArray(input.ideas) ? input.ideas : []
if (!ideas.length) throw new ToolRefusal("Give at least one idea.")
const limit = Math.min(Number(input.limit ?? 3) || 3, 10)
@@ -418,6 +460,7 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
status: "ok",
results: results.map((repo) => ({ title: repo.fullName ?? repo.name, url: repo.url })),
searchedAt: new Date().toISOString(),
run,
})
lines.push(
`- ${idea.id} "${idea.title}" — ${results.length ? "related projects:" : "no related projects found (candidate for novel):"}`,
@@ -430,6 +473,7 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
status: "failed",
results: [],
searchedAt: new Date().toISOString(),
run,
})
lines.push(
`- ${idea.id}: prior-art search failed (${error instanceof Error ? error.message : String(error)}); UNVERIFIED — do not claim novelty`,
@@ -444,5 +488,97 @@ export function harvestTools(context: HarvestOpsContext): EsToolDef[] {
].join("\n")
},
},
{
name: "es_harvest_target",
description:
"Grill, factory, scout or harvester: scan up to 5 sources for a license-and-verdict check (no subagent, no model calls). Fail-closed: unverified, copyleft, unknown or non-whitelisted licenses become clean-room. Returns the verdicts as JSON.",
input: object(
{
objective: { type: "string", description: "What the verdicts are for." },
targets: {
type: "array",
description:
'Sources (at most 5): "github:owner/repo", "gitlab:group/project", "git:https://…", "npm:name", "local:./path".',
items: { type: "string" },
},
proposed: {
type: "string",
enum: ["depend", "vendor"],
description: "Verdict to check each target against (default depend).",
},
run: { type: "string", description: "Run id the profiles are written under." },
},
["objective", "targets"],
),
execute: async (input, toolContext) => {
if (!mayTarget(toolContext.agent))
throw new ToolRefusal("Only the grill, factory, scout and harvester seats may scan a harvest target.")
const run = runOf(input)
const objective = String(input.objective ?? "").trim()
if (!objective) throw new ToolRefusal("Give the check an objective.")
const targets = Array.isArray(input.targets) ? input.targets.map(String) : []
if (!targets.length) throw new ToolRefusal("Give at least one target.")
if (targets.length > 5) throw new ToolRefusal(`Give at most 5 targets per call (asked for ${targets.length}).`)
const proposed = input.proposed === "vendor" ? "vendor" : "depend"
const allowed: readonly string[] = context.whitelist ?? DEFAULT_LICENSE_WHITELIST
const seen = new Set((await readProfiles(root, run)).map((profile) => profile.id))
const verdicts: Array<{
target: string
license: { spdx: string; family: string; verified: boolean }
verdict: string
attribution?: string
provenance: { origin: string; commit?: string; scannedAt: string }
}> = []
const lines = [`Target verdicts for "${objective}" (run "${run}", proposed ${proposed}):`]
for (const spec of targets) {
let source: HarvestSource
try {
source = parseSource(spec)
assertSourceAllowed(root, source, sourcePolicy)
} catch (error) {
throw new ToolRefusal(error instanceof Error ? error.message : String(error))
}
const label =
source.kind === "local"
? path.basename(canonicalPath(source.path, root)) || spec
: source.kind === "git"
? (source.url.split("/").filter(Boolean).pop() ?? spec)
: source.kind === "registry"
? `${source.registry}:${source.name}`
: (source.repo.split("/").filter(Boolean).pop() ?? spec)
let id = candidateId(label)
while (seen.has(id) || id === "notes" || id === "clean-room" || id === "runs") id = `${id}-2`
seen.add(id)
let profile: HarvestProfile
try {
profile = await scanSource(root, id, source, {
...api(),
...sourcePolicy,
...(context.clone ? { clone: context.clone } : {}),
...(toolContext.signal ? { signal: toolContext.signal } : {}),
})
} catch (error) {
throw new ToolRefusal(`Scan of "${spec}" failed: ${error instanceof Error ? error.message : String(error)}`)
}
await writeProfile(root, profile, run)
const checked = checkVerdict(proposed, profile.license, allowed)
verdicts.push({
target: spec,
license: { spdx: profile.license.spdx, family: profile.license.family, verified: profile.license.verified },
verdict: checked.verdict,
...(checked.attribution ? { attribution: checked.attribution } : {}),
provenance: {
origin: profile.origin ?? spec,
...(profile.commit ? { commit: profile.commit } : {}),
scannedAt: profile.scannedAt,
},
})
lines.push(
`- ${spec} → ${checked.verdict} (license ${profile.license.spdx}, ${profile.license.family}${profile.license.verified ? ", verified" : ", UNVERIFIED"})${checked.downgraded ? ` — ${checked.downgraded}` : ""}`,
)
}
return [...lines, "", "--- verdicts (JSON) ---", JSON.stringify(verdicts)].join("\n")
},
},
]
}
+24 -15
View File
@@ -1,36 +1,45 @@
// The record of prior-art searches: what es_harvest_prior_art actually found.
// It lives under .factory/runtime (a control dir no agent may write), so a
// brainstorm can only cite prior art that a search returned.
// brainstorm can only cite prior art that a search returned. Records validate
// on write and on read (#109): a corrupt record is refused, never trusted.
import path from "node:path"
import { factoryLayout } from "../layout.ts"
import { type PriorArtSearch, PriorArtSearchSchema } from "../schema/harvest.ts"
import { readJson, writeJson } from "../util/fs.ts"
export interface PriorArtSearch {
readonly idea: string
readonly query: string
readonly status: "ok" | "failed"
readonly results: ReadonlyArray<{ readonly title: string; readonly url: string }>
readonly searchedAt: string
}
export type { PriorArtSearch }
const recordFile = (root: string) => path.join(factoryLayout(root).runtime, "harvest", "prior-art.json")
export async function readPriorArt(root: string): Promise<PriorArtSearch[]> {
return (await readJson<PriorArtSearch[]>(recordFile(root)).catch(() => undefined)) ?? []
const raw = (await readJson<unknown[]>(recordFile(root)).catch(() => undefined)) ?? []
// Corrupt entries are dropped, never trusted; writers are refused outright.
return raw.flatMap((entry) => {
const parsed = PriorArtSearchSchema.safeParse(entry)
return parsed.success ? [parsed.data] : []
})
}
/** Add searches to the record (latest search per idea wins). */
/** Add searches to the record (latest search per run+idea wins). */
export async function recordPriorArt(root: string, searches: readonly PriorArtSearch[]): Promise<void> {
const ideas = new Set(searches.map((search) => search.idea))
const kept = (await readPriorArt(root)).filter((search) => !ideas.has(search.idea))
for (const search of searches) {
const parsed = PriorArtSearchSchema.safeParse(search)
if (!parsed.success) throw new Error(`invalid prior-art search: ${parsed.error.message}`)
}
const keys = new Set(searches.map((search) => `${search.run ?? ""}::${search.idea}`))
const kept = (await readPriorArt(root)).filter((search) => !keys.has(`${search.run ?? ""}::${search.idea}`))
await writeJson(recordFile(root), [...kept, ...searches])
}
/** URLs any recorded, successful search returned. */
export async function priorArtUrls(root: string): Promise<Set<string>> {
/**
* URLs any recorded, successful search returned. A brainstorm run sees its
* own searches plus legacy global records (written before runs); other runs'
* searches do not satisfy it.
*/
export async function priorArtUrls(root: string, run?: string): Promise<Set<string>> {
return new Set(
(await readPriorArt(root))
.filter((search) => search.status === "ok")
.filter((search) => search.status === "ok" && (search.run === undefined || search.run === run))
.flatMap((search) => search.results.map((result) => result.url)),
)
}
+84 -32
View File
@@ -3,6 +3,7 @@
// VENDOR-PLAN.md and clean-room/<candidate>-<feature>.md.
import { readdir, rm } from "node:fs/promises"
import path from "node:path"
import { DEFAULT_RUN } from "../brainstorm/store.ts"
import { factoryLayout } from "../layout.ts"
import {
HARVEST_VERSION,
@@ -17,73 +18,122 @@ import {
} from "../schema/harvest.ts"
import { atomicWrite, readJson, writeJson } from "../util/fs.ts"
export function harvestPaths(root: string) {
export function harvestPaths(root: string, runId: string = DEFAULT_RUN) {
const layout = factoryLayout(root)
const run = factoryLayout(root).harvestRun(runId)
return {
dir: layout.harvest,
plan: layout.harvestPlan,
matrix: layout.harvestMatrix,
result: layout.harvestResult,
report: layout.harvestReport,
vendorPlan: layout.harvestVendorPlan,
cleanRoom: layout.harvestCleanRoom,
profile: (id: string) => path.join(layout.harvest, id, "profile.json"),
dir: run,
plan: layout.harvestRunPlan(runId),
matrix: layout.harvestRunMatrix(runId),
result: layout.harvestRunResult(runId),
report: layout.harvestRunReport(runId),
vendorPlan: layout.harvestRunVendorPlan(runId),
cleanRoom: layout.harvestRunCleanRoom(runId),
profile: (id: string) => layout.harvestRunProfile(runId, id),
}
}
const invalid = (file: string, error: unknown) =>
new Error(`invalid ${file}: ${error instanceof Error ? error.message : String(error)}`)
export async function readHarvestPlan(root: string): Promise<HarvestPlan | undefined> {
const raw = await readJson(harvestPaths(root).plan)
export async function readHarvestPlan(root: string, runId: string = DEFAULT_RUN): Promise<HarvestPlan | undefined> {
const raw = await readJson(harvestPaths(root, runId).plan)
if (raw === undefined && runId === DEFAULT_RUN) {
// Pre-runs checkouts keep working: legacy top-level files read as the default run (#109).
const legacy = await readJson(factoryLayout(root).harvestPlan)
if (legacy === undefined) return undefined
const parsedLegacy = HarvestPlanSchema.safeParse(legacy)
if (!parsedLegacy.success) throw invalid("harvest/plan.json", parsedLegacy.error)
return parsedLegacy.data
}
if (raw === undefined) return undefined
const parsed = HarvestPlanSchema.safeParse(raw)
if (!parsed.success) throw invalid("harvest/plan.json", parsed.error)
return parsed.data
}
export const writeHarvestPlan = (root: string, plan: HarvestPlan) => writeJson(harvestPaths(root).plan, plan)
export const writeHarvestPlan = (root: string, plan: HarvestPlan, runId: string = DEFAULT_RUN) =>
writeJson(harvestPaths(root, runId).plan, plan)
/** Replace the run: freeze the plan and remove every prior artifact. */
export async function startHarvestRun(root: string, plan: HarvestPlan): Promise<void> {
await rm(harvestPaths(root).dir, { recursive: true, force: true })
await writeHarvestPlan(root, plan)
export async function startHarvestRun(root: string, plan: HarvestPlan, runId: string = DEFAULT_RUN): Promise<void> {
await rm(harvestPaths(root, runId).dir, { recursive: true, force: true })
await writeHarvestPlan(root, plan, runId)
}
export async function readProfile(root: string, id: string): Promise<HarvestProfile | undefined> {
const raw = await readJson(harvestPaths(root).profile(id))
/** Reserved names under .factory/harvest that are never candidate profiles. */
const RESERVED_HARVEST_DIRS = new Set(["notes", "clean-room", "runs"])
export async function readProfile(
root: string,
id: string,
runId: string = DEFAULT_RUN,
): Promise<HarvestProfile | undefined> {
const raw = await readJson(harvestPaths(root, runId).profile(id))
if (raw === undefined && runId === DEFAULT_RUN && !RESERVED_HARVEST_DIRS.has(id)) {
const legacy = await readJson(path.join(factoryLayout(root).harvest, id, "profile.json"))
if (legacy === undefined) return undefined
const parsedLegacy = HarvestProfileSchema.safeParse(legacy)
if (!parsedLegacy.success) throw invalid(`harvest/${id}/profile.json`, parsedLegacy.error)
return parsedLegacy.data
}
if (raw === undefined) return undefined
const parsed = HarvestProfileSchema.safeParse(raw)
if (!parsed.success) throw invalid(`harvest/${id}/profile.json`, parsed.error)
return parsed.data
}
export const writeProfile = (root: string, profile: HarvestProfile) =>
writeJson(harvestPaths(root).profile(profile.id), profile)
export const writeProfile = (root: string, profile: HarvestProfile, runId: string = DEFAULT_RUN) =>
writeJson(harvestPaths(root, runId).profile(profile.id), profile)
/** Every scanned profile under .factory/harvest/<id>/profile.json. */
export async function readProfiles(root: string): Promise<HarvestProfile[]> {
const entries = await readdir(harvestPaths(root).dir, { withFileTypes: true }).catch(() => [])
/** Every scanned profile for a run (legacy flat profiles read as the default run). */
export async function readProfiles(root: string, runId: string = DEFAULT_RUN): Promise<HarvestProfile[]> {
const entries = await readdir(harvestPaths(root, runId).dir, { withFileTypes: true }).catch(() => [])
const out: HarvestProfile[] = []
for (const entry of entries.filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
const profile = await readProfile(root, entry.name)
const profile = await readProfile(root, entry.name, runId)
if (profile) out.push(profile)
}
if (runId === DEFAULT_RUN) {
const legacy = await readdir(factoryLayout(root).harvest, { withFileTypes: true }).catch(() => [])
for (const entry of legacy
.filter((item) => item.isDirectory() && !RESERVED_HARVEST_DIRS.has(item.name))
.sort((a, b) => a.name.localeCompare(b.name))) {
if (out.some((profile) => profile.id === entry.name)) continue
const profile = await readProfile(root, entry.name, runId)
if (profile) out.push(profile)
}
}
return out
}
export async function readMatrix(root: string): Promise<HarvestMatrix | undefined> {
const raw = await readJson(harvestPaths(root).matrix)
export async function readMatrix(root: string, runId: string = DEFAULT_RUN): Promise<HarvestMatrix | undefined> {
const raw = await readJson(harvestPaths(root, runId).matrix)
if (raw === undefined && runId === DEFAULT_RUN) {
const legacy = await readJson(factoryLayout(root).harvestMatrix)
if (legacy === undefined) return undefined
const parsedLegacy = HarvestMatrixSchema.safeParse(legacy)
if (!parsedLegacy.success) throw invalid("harvest/matrix.json", parsedLegacy.error)
return parsedLegacy.data
}
if (raw === undefined) return undefined
const parsed = HarvestMatrixSchema.safeParse(raw)
if (!parsed.success) throw invalid("harvest/matrix.json", parsed.error)
return parsed.data
}
export const writeMatrix = (root: string, matrix: HarvestMatrix) => writeJson(harvestPaths(root).matrix, matrix)
export const writeMatrix = (root: string, matrix: HarvestMatrix, runId: string = DEFAULT_RUN) =>
writeJson(harvestPaths(root, runId).matrix, matrix)
export async function readHarvestResult(root: string): Promise<HarvestResult | undefined> {
const raw = await readJson(harvestPaths(root).result)
export async function readHarvestResult(root: string, runId: string = DEFAULT_RUN): Promise<HarvestResult | undefined> {
const raw = await readJson(harvestPaths(root, runId).result)
if (raw === undefined && runId === DEFAULT_RUN) {
const legacy = await readJson(factoryLayout(root).harvestResult)
if (legacy === undefined) return undefined
const parsedLegacy = HarvestResultSchema.safeParse(legacy)
if (!parsedLegacy.success) throw invalid("harvest/harvest.json", parsedLegacy.error)
return parsedLegacy.data
}
if (raw === undefined) return undefined
const parsed = HarvestResultSchema.safeParse(raw)
if (!parsed.success) throw invalid("harvest/harvest.json", parsed.error)
@@ -100,6 +150,7 @@ export async function writeHarvestResult(
vendorPlan: string
cleanRoom: Array<{ candidate: string; feature: string; content: string }>
},
runId: string = DEFAULT_RUN,
): Promise<HarvestResult> {
const result = HarvestResultSchema.parse({
version: HARVEST_VERSION,
@@ -108,12 +159,13 @@ export async function writeHarvestResult(
matrix,
completedAt: new Date().toISOString(),
})
await writeJson(harvestPaths(root).result, result)
await atomicWrite(harvestPaths(root).report, `${rendered.report}\n`)
await atomicWrite(harvestPaths(root).vendorPlan, `${rendered.vendorPlan}\n`)
const paths = harvestPaths(root, runId)
await writeJson(paths.result, result)
await atomicWrite(paths.report, `${rendered.report}\n`)
await atomicWrite(paths.vendorPlan, `${rendered.vendorPlan}\n`)
for (const spec of rendered.cleanRoom) {
const file = path.join(
harvestPaths(root).cleanRoom,
paths.cleanRoom,
`${spec.candidate}-${spec.feature.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}.md`,
)
await atomicWrite(file, spec.content)
+592
View File
@@ -0,0 +1,592 @@
// Deterministic distillation of telemetry into reviewable proposals (#136).
// Clustering keys on (seat, failure kind, rule or category): the same gate
// rule, audit category or QA failure reason across runs, with the seat the
// S3.4 failure data names and never a defaulted one. Each surviving cluster
// becomes exactly one proposal — prompt guidance (a version-bumped patch),
// gate tuning (an explanation only: gates.json is a control file a human
// applies with rebaseline) or a domain-pack candidate (validated against
// DomainPackSchema). Proposals are only written under the output dir; nothing
// is ever applied automatically. A proposal the gate drops is reported with
// its reasons (never silently) in the command output and `dropped.json`.
//
// The "code disposes" gate (clean-room): a proposal is disposed unless every
// evidence quote appears verbatim in the source telemetry and every model
// numeral it names is a model the telemetry actually records. Hallucinated
// counts and model names cannot become reviewable proposals.
import { mkdir, readFile } from "node:fs/promises"
import path from "node:path"
import { loadPrompt } from "../agents/assets.ts"
import { DomainPackSchema } from "../schema/domain.ts"
import {
type Cluster,
type ClusterKind,
type EvalTelemetry,
type FailureEntry,
type Proposal,
type ProposalKind,
ProposalSchema,
type RunTelemetry,
type Telemetry,
} from "../schema/improve.ts"
import { compareStrings } from "../util/compare.ts"
import { atomicWrite, withLock } from "../util/fs.ts"
import { shortHash } from "../util/hash.ts"
export interface DistillThresholds {
/** Minimum total occurrences of one key (default 3). */
readonly minOccurrences?: number
/** Minimum distinct runs carrying the key (default 2). */
readonly minRuns?: number
}
export const DEFAULT_THRESHOLDS = { minOccurrences: 3, minRuns: 2 } as const
interface Signal {
readonly seat?: string
readonly kind: ClusterKind
/** The rule, category or failure text the cluster keys on. */
readonly facet: string
readonly runId: string
readonly evidence: string
}
/** Failure kinds that cluster into proposals (anything else is not clusterable). */
const CLUSTERABLE: ReadonlySet<string> = new Set(["gate", "audit", "qa", "replan", "eval"])
/** The cluster key: (seat, kind, facet), with no defaulted seat. */
const signalKey = (seat: string | undefined, kind: ClusterKind, facet: string): string =>
seat ? `${seat}:${kind}:${facet}` : `${kind}:${facet}`
/** Round-robin evidence across contributing logs (occurrence `index` of `count`). */
const gateEvidence = (run: RunTelemetry, rule: string, index: number): string => {
const rejection = run.gateRejections.find((entry) => entry.rule === rule)
const logs = rejection?.logs ?? []
if (logs.length === 0) return `run ${run.id}: gate rejection ${rule}`
const log = logs[index % logs.length]!
return `run ${run.id} gate-log ${log}: ${rule}`
}
/** Round-robin evidence across contributing audit records. */
const auditEvidence = (run: RunTelemetry, category: string, index: number): string => {
const slash = category.indexOf("/")
const found = run.auditFindings.find(
(entry) => entry.kind === category.slice(0, slash) && entry.severity === category.slice(slash + 1),
)
const sources = found?.sources ?? []
if (sources.length === 0) return `run ${run.id}: audit finding ${category}`
const source = sources[index % sources.length]!
return `run ${run.id} audit ${source}: ${category}`
}
/** Evidence for a chain-backed failure: the chain entry's hash, or a plain quote. */
const chainEvidence = (run: RunTelemetry, action: string, phase: string | undefined, reason: string): string => {
const hit = run.auditChain.trail.find(
(entry) => entry.action === action && (entry.phase ?? "") === (phase ?? "") && (entry.reason ?? reason) === reason,
)
if (!hit) return `run ${run.id}: ${action}${phase ? ` ${phase}` : ""} ${reason}`
return (
`run ${run.id} audit-chain #${hit.seq} ${hit.hash}: ${hit.action}` +
`${hit.phase ? ` ${hit.phase}` : ""}${hit.reason ? ` ${hit.reason}` : ""}`
)
}
/** Evidence for one S3.4 failure entry, resolved against the run's links. */
function failureEvidence(run: RunTelemetry, phaseId: string, failure: FailureEntry, index: number): string {
if (failure.kind === "gate" && failure.rule) return gateEvidence(run, failure.rule, index)
if (failure.kind === "audit" && failure.category) return auditEvidence(run, failure.category, index)
if (failure.kind === "qa") return chainEvidence(run, "qa.fail", phaseId, failure.reason)
if (failure.kind === "replan") return chainEvidence(run, "phase.replan", phaseId, failure.reason)
return `run ${run.id}: ${failure.kind} ${failure.reason}`
}
/**
* Signals from one run's S3.4 phase failures: the seat the data names (never
* a default), keyed on (seat, kind, rule or category). QA clusters on the
* failure reason itself, so the same QA failure reason clusters (#136).
*/
function phaseFailureSignals(run: RunTelemetry): Signal[] {
const out: Signal[] = []
const seen = new Map<string, number>()
for (const phase of run.phases) {
for (const failure of phase.failures) {
if (!CLUSTERABLE.has(failure.kind)) continue
const kind = failure.kind as ClusterKind
const facet = failure.rule ?? failure.category ?? (kind === "qa" ? failure.reason : kind)
const seenKey = `${kind}:${facet}`
const index = seen.get(seenKey) ?? 0
seen.set(seenKey, index + 1)
out.push({
...(failure.seat ? { seat: failure.seat } : {}),
kind,
facet,
runId: run.id,
evidence: failureEvidence(run, phase.id, failure, index),
})
}
}
return out
}
/** Halt evidence: the chain's halt entry hash when the trail records it. */
const haltEvidence = (run: RunTelemetry): string => {
const halt = run.halt!
const hit = [...run.auditChain.trail].reverse().find((entry) => entry.action === "stage.halted")
if (hit) return `run ${run.id} audit-chain #${hit.seq} ${hit.hash}: stage.halted ${halt.reason}`
return `run ${run.id}: halt from ${halt.from}: ${halt.reason}`
}
function runSignals(run: RunTelemetry): Signal[] {
// Trusted phases carry the seat-attributed S3.4 failures; the aggregates
// below are their fallback for unsealed or phaseless runs, keyed the same
// way so sealed and unsealed harvests of one run cluster alike.
if (run.phases.length > 0) {
const halted = run.halt
? [{ kind: "eval" as const, facet: `halt/${run.halt.from}`, runId: run.id, evidence: haltEvidence(run) }]
: []
return [...phaseFailureSignals(run), ...halted]
}
const out: Signal[] = []
for (const rejection of run.gateRejections)
for (let index = 0; index < rejection.count; index++)
out.push({
kind: "gate",
facet: rejection.rule,
runId: run.id,
evidence: gateEvidence(run, rejection.rule, index),
})
for (const finding of run.auditFindings) {
const category = `${finding.kind}/${finding.severity}`
for (let index = 0; index < finding.count; index++)
out.push({ kind: "audit", facet: category, runId: run.id, evidence: auditEvidence(run, category, index) })
}
if (run.halt) out.push({ kind: "eval", facet: `halt/${run.halt.from}`, runId: run.id, evidence: haltEvidence(run) })
return out
}
function evalSignals(evalCase: EvalTelemetry): Signal[] {
if (evalCase.pass) return []
const failures = evalCase.failures.length > 0 ? evalCase.failures : ["failed"]
return failures.map((failure) => ({
kind: "eval" as const,
// Key on the failure text itself: the same reason across cases is
// one cluster; the case and its aggregate file stay in the evidence.
facet: failure,
runId: `eval/${evalCase.case}`,
evidence: `eval ${evalCase.case} (${evalCase.source}): ${failure}`,
}))
}
function signals(telemetry: Telemetry): Signal[] {
const out: Signal[] = []
for (const run of telemetry.runs) out.push(...runSignals(run))
for (const evalCase of telemetry.evals) out.push(...evalSignals(evalCase))
return out
}
/**
* Every verbatim evidence string the telemetry yields (audit-chain hashes,
* gate-log and audit-record paths, eval aggregate files — never synthesized
* phrases, sorted). The "code disposes" gate checks proposal quotes against
* exactly this set: a quote counts as verbatim only when the harvester said it.
*/
export function signalEvidence(telemetry: Telemetry): string[] {
const evidence = [...new Set(signals(telemetry).map((signal) => signal.evidence))]
evidence.sort(compareStrings)
return evidence
}
interface ClusterGroup {
kind: ClusterKind
facet: string
seat?: string
runIds: Set<string>
evidence: string[]
}
const toCluster = (key: string, group: ClusterGroup): Cluster => {
const runIds = [...group.runIds]
runIds.sort(compareStrings)
const evidence = [...new Set(group.evidence)]
evidence.sort(compareStrings)
return {
key,
kind: group.kind,
label: group.facet,
...(group.seat ? { seat: group.seat } : {}),
occurrences: group.evidence.length,
runIds,
evidence,
}
}
/**
* Deterministic clustering: group signals by (seat, kind, facet), count
* occurrences, collect distinct run ids and verbatim evidence. Sorted by key.
*/
export function clusterTelemetry(telemetry: Telemetry, thresholds: DistillThresholds = {}): Cluster[] {
const minOccurrences = thresholds.minOccurrences ?? DEFAULT_THRESHOLDS.minOccurrences
const minRuns = thresholds.minRuns ?? DEFAULT_THRESHOLDS.minRuns
const groups = new Map<string, ClusterGroup>()
for (const signal of signals(telemetry)) {
const key = signalKey(signal.seat, signal.kind, signal.facet)
let group = groups.get(key)
if (!group) {
group = {
kind: signal.kind,
facet: signal.facet,
...(signal.seat ? { seat: signal.seat } : {}),
runIds: new Set(),
evidence: [],
}
groups.set(key, group)
}
group.runIds.add(signal.runId)
group.evidence.push(signal.evidence)
}
const entries = [...groups.entries()]
entries.sort(([a], [b]) => compareStrings(a, b))
const clusters: Cluster[] = []
for (const [key, group] of entries) {
if (group.evidence.length < minOccurrences) continue
if (group.runIds.size < minRuns) continue
clusters.push(toCluster(key, group))
}
return clusters
}
const KIND_FOR_CLUSTER: Record<ClusterKind, ProposalKind> = {
gate: "gate-tuning",
audit: "prompt-guidance",
qa: "prompt-guidance",
replan: "prompt-guidance",
eval: "domain-pack",
}
const slug = (text: string): string => {
const dashed = text.toLowerCase().replace(/[^a-z0-9]+/g, "-")
let start = 0
while (dashed[start] === "-") start += 1
let end = dashed.length
while (end > start && dashed[end - 1] === "-") end -= 1
return dashed.slice(start, end).slice(0, 48) || "proposal"
}
/** Stable proposal id from the cluster key (deterministic across runs). */
export const proposalId = (kind: ProposalKind, cluster: Pick<Cluster, "key">): string =>
`${kind}-${slug(cluster.key)}-${shortHash(cluster.key)}`
/**
* Prompt asset for a prompt-guidance cluster: the failure's own seat when it
* names a real prompt, else the asset for the failure kind. The old seat
* guess (`programmer` for everything non-invariant) is gone: QA failures
* guide the QA prompt, replans guide the manager prompt, and audit findings
* guide their auditor seat's prompt.
*/
async function promptAssetFor(cluster: Cluster): Promise<{ id: string; version: number; body: string }> {
const fallback = cluster.kind === "qa" ? "qa-functional" : cluster.kind === "replan" ? "manager" : "auditor-thesis"
for (const id of [...new Set([cluster.seat, fallback])].filter((entry): entry is string => !!entry)) {
try {
const asset = await loadPrompt(id)
return { id, version: asset.meta.version, body: asset.body }
} catch {
// Not a prompt seat (e.g. the chain actor `qa`): try the kind fallback.
}
}
throw new Error(`no prompt asset for cluster ${cluster.key}`)
}
const promptFor = (promptId: string): string => `packages/core/assets/prompts/${promptId}.md`
const evidenceSection = (evidence: readonly string[]): string =>
["## Evidence", ...evidence.map((quote) => `- ${quote}`), ``].join("\n")
interface RenderedProposal {
readonly kind: ProposalKind
readonly id: string
readonly rationale: string
readonly files: Record<string, string>
}
function renderGateTuning(cluster: Cluster): RenderedProposal {
const kind = "gate-tuning" as const
const rationale =
`Gate rule "${cluster.label}" rejected ${cluster.occurrences} time(s) across ${cluster.runIds.length} run(s) ` +
`(${cluster.runIds.join(", ")}). This proposal only explains the pattern: gates.json is a factory control ` +
`file, so a human applies any tuning with \`es rebaseline\`. Nothing here changes a gate on its own.`
return {
kind,
id: proposalId(kind, cluster),
rationale,
files: {
"gates.md": [
`# Gate-tuning proposal: ${cluster.label}`,
``,
rationale,
``,
evidenceSection(cluster.evidence),
`## Suggested direction (human applies with \`es rebaseline\`)`,
``,
`Consider whether rule "${cluster.label}" needs a waiver scope, a fix in the seats' guidance, or a ` +
`tighter check. Do not apply this file mechanically: review the evidence first.`,
``,
].join("\n"),
},
}
}
/** A unified-diff hunk replacing one line with context on both sides. */
function replaceHunk(lines: readonly string[], index: number, oldLine: string, newLine: string): string[] {
const before = lines.slice(Math.max(0, index - 2), index)
const after = lines.slice(index + 1, index + 3)
const start = index - before.length + 1
const count = before.length + 1 + after.length
return [
`@@ -${start},${count} +${start},${count} @@`,
...before.map((line) => ` ${line}`),
`-${oldLine}`,
`+${newLine}`,
...after.map((line) => ` ${line}`),
]
}
/** A unified-diff hunk appending one line at the end of the file. */
function appendHunk(lines: readonly string[], newLine: string): string[] {
const tail = lines.slice(-3)
const start = lines.length - tail.length + 1
return [
`@@ -${start},${tail.length} +${start},${tail.length + 1} @@`,
...tail.map((line) => ` ${line}`),
`+${newLine}`,
]
}
async function renderPromptGuidance(cluster: Cluster): Promise<RenderedProposal> {
const kind = "prompt-guidance" as const
const { id: promptId, version, body } = await promptAssetFor(cluster)
const next = version + 1
const pitfall = `Known pitfall (distilled from ${cluster.occurrences} occurrence(s) of ${cluster.label}): re-read the evidence above before recording a verdict.`
const source = await readFile(promptFor(promptId), "utf8").catch(() => undefined)
const lines = (source ?? `${body}\n`).replace(/\n$/, "").split("\n")
const versionLine = lines.findIndex((line) => /^version:\s*\d+\s*$/.test(line))
const hunks: string[] = []
if (versionLine > 0) hunks.push(...replaceHunk(lines, versionLine, `version: ${version}`, `version: ${next}`))
hunks.push(...appendHunk(lines, pitfall))
const rationale =
`Failure signal "${cluster.label}" (${cluster.kind}) recurred ${cluster.occurrences} time(s) across ${cluster.runIds.length} run(s). ` +
`This patch adds one known-pitfall line to the ${promptId} prompt and bumps its frontmatter to v${next}, ` +
`so the assets drift check stays green when a human applies it. Review the evidence before applying.`
return {
kind,
id: proposalId(kind, cluster),
rationale,
files: {
"prompt.patch": [`--- a/${promptFor(promptId)}`, `+++ b/${promptFor(promptId)}`, ...hunks, ``].join("\n"),
"rationale.md": [
`# Prompt-guidance proposal: ${promptId}`,
``,
rationale,
``,
evidenceSection(cluster.evidence),
].join("\n"),
},
}
}
function renderDomainPack(cluster: Cluster): RenderedProposal {
const kind = "domain-pack" as const
const pack = DomainPackSchema.parse({
packId: `distilled-${slug(cluster.label)}`.slice(0, 48),
description:
`Distilled from ${cluster.occurrences} occurrence(s) of ${cluster.label} across ` +
`${cluster.runIds.join(", ")}: research touching this failure domain must cite primary evidence. ` +
`Human-reviewed proposal; not applied.`,
tierWeights: { __default__: 1 },
mandatoryTags: ["VERIFIED"],
})
const rationale =
`Failure signal "${cluster.label}" recurred ${cluster.occurrences} time(s) across ${cluster.runIds.length} run(s). ` +
`This candidate domain pack (${pack.packId}) would require VERIFIED evidence on claims in this domain. ` +
`It is schema-valid but unreviewed: a human adopts it by adding it to assets/domain-packs/.`
return {
kind,
id: proposalId(kind, cluster),
rationale,
files: {
"pack.json": `${JSON.stringify(pack, null, 2)}\n`,
"rationale.md": [
`# Domain-pack proposal: ${pack.packId}`,
``,
rationale,
``,
evidenceSection(cluster.evidence),
].join("\n"),
},
}
}
/**
* Render one proposal's files (but write nothing): proposal.json metadata is
* assembled by `writeProposals`. Prompt patches are unified diffs against the
* seat's prompt asset: a version-bump hunk plus a known-pitfall insertion.
*/
export async function renderProposalFiles(cluster: Cluster): Promise<RenderedProposal> {
const kind = KIND_FOR_CLUSTER[cluster.kind]
if (kind === "gate-tuning") return renderGateTuning(cluster)
if (kind === "prompt-guidance") return renderPromptGuidance(cluster)
return renderDomainPack(cluster)
}
/** Distill every surviving cluster into a validated proposal (deterministic order). */
export async function distillProposals(telemetry: Telemetry, thresholds: DistillThresholds = {}): Promise<Proposal[]> {
const clusters = clusterTelemetry(telemetry, thresholds)
const rendered = await Promise.all(clusters.map((cluster) => renderProposalFiles(cluster)))
const out = rendered.map((item, index) => {
const files = Object.keys(item.files)
files.sort(compareStrings)
return ProposalSchema.parse({
id: item.id,
kind: item.kind,
cluster: clusters[index],
rationale: item.rationale,
files,
})
})
out.sort((a, b) => compareStrings(a.id, b.id))
return out
}
// ------------------------------------------------- the "code disposes" gate
/**
* Model-numeral candidates: a known model family name with a version number
* (`gpt-4`, `llama-8b`). The vendor list keeps the pattern linear; anything
* else never reads as a model claim.
*/
const MODEL_NUMERAL = /\b(?:gpt|claude|gemini|llama|mistral|qwen)[-_. ]?\d[\w.]*\b/gi
const KNOWN_NON_MODELS = new Set(["utf-8", "utf8", "sha-256", "sha256", "base-64", "base64", "v1", "v2"])
/** Model strings the telemetry actually records (eval models). */
export function telemetryModels(telemetry: Telemetry): Set<string> {
const models = new Set<string>()
for (const evalCase of telemetry.evals) {
if (evalCase.model) models.add(evalCase.model.toLowerCase())
}
return models
}
const numeralAllowed = (numeral: string, allowed: ReadonlySet<string>): boolean => {
if (KNOWN_NON_MODELS.has(numeral)) return true
for (const model of allowed) {
if (model.includes(numeral)) return true
if (numeral.includes(model)) return true
}
return false
}
/**
* Why this proposal is disposed (undefined when it stands): an evidence
* quote must appear verbatim in the source telemetry, and a model numeral
* must be a model the telemetry records. Returns every reason, sorted.
*/
export function disposeReason(proposal: Proposal, telemetry: Telemetry): string[] {
const verbatim = new Set(signalEvidence(telemetry))
const reasons: string[] = []
for (const quote of proposal.cluster.evidence) {
if (!verbatim.has(quote)) reasons.push(`evidence is not a verbatim quote: ${quote.slice(0, 80)}`)
}
const allowed = telemetryModels(telemetry)
const text = `${proposal.rationale} ${proposal.cluster.label}`
const matches = new Set(text.match(MODEL_NUMERAL) ?? [])
for (const match of matches) {
const numeral = match.toLowerCase()
if (!numeralAllowed(numeral, allowed)) reasons.push(`hallucinated model numeral: ${match}`)
}
reasons.sort(compareStrings)
return reasons
}
/** Every proposal that survives the gate (throws on none — callers report). */
export function undisposed(proposals: Proposal[], telemetry: Telemetry): Proposal[] {
return proposals.filter((proposal) => disposeReason(proposal, telemetry).length === 0)
}
// ------------------------------------------------- writing (proposals only)
export interface WrittenProposal extends Proposal {
/** Absolute directory holding this proposal's files. */
readonly dir: string
}
async function writeOne(out: string, proposal: Proposal, files: Record<string, string>): Promise<WrittenProposal> {
const dir = path.join(out, proposal.id)
// Atomic files under the proposal dir's lock (S3.3): a crashed or
// concurrent distill never leaves a torn proposal.json behind.
return withLock(dir, async () => {
await mkdir(dir, { recursive: true })
await atomicWrite(path.join(dir, "proposal.json"), `${JSON.stringify(proposal, null, 2)}\n`)
await Promise.all(Object.entries(files).map(([name, content]) => atomicWrite(path.join(dir, name), content)))
return { ...proposal, dir }
})
}
/**
* Write proposals under `<out>/<id>/` (proposal.json plus the rendered
* files). Writes nowhere else: prompts, packs and gates.json are never
* touched — a human applies what they review.
*/
export async function writeProposals(
out: string,
proposals: Proposal[],
rendered: Map<string, Record<string, string>>,
): Promise<WrittenProposal[]> {
const written = await Promise.all(
proposals.map((proposal) => writeOne(out, proposal, rendered.get(proposal.id) ?? {})),
)
written.sort((a, b) => compareStrings(a.id, b.id))
return written
}
// ------------------------------------------------- dropped proposals (never silent)
/** A proposal the "code disposes" gate dropped, with every reason. */
export interface DroppedProposal {
readonly id: string
readonly kind: ProposalKind
readonly key: string
readonly reasons: readonly string[]
}
/** Every disposed proposal with its reasons, sorted by id (deterministic). */
export function disposedWithReasons(
proposals: Proposal[],
telemetry: Telemetry,
): Array<{ proposal: Proposal; reasons: string[] }> {
const out = proposals
.map((proposal) => ({ proposal, reasons: disposeReason(proposal, telemetry) }))
.filter((entry) => entry.reasons.length > 0)
out.sort((a, b) => compareStrings(a.proposal.id, b.proposal.id))
return out
}
/**
* Write `<out>/dropped.json` (every disposed proposal with its reason)
* atomically under a lock, so a rerun is byte-identical. Nothing the gate
* drops is ever silent.
*/
export async function writeDropped(
out: string,
dropped: ReadonlyArray<{ proposal: Proposal; reasons: readonly string[] }>,
): Promise<string> {
const file = path.join(out, "dropped.json")
const entries: DroppedProposal[] = dropped.map((entry) => ({
id: entry.proposal.id,
kind: entry.proposal.kind,
key: entry.proposal.cluster.key,
reasons: [...entry.reasons],
}))
entries.sort((a, b) => compareStrings(a.id, b.id))
const body = `${JSON.stringify(entries, null, 2)}\n`
await withLock(file, () => atomicWrite(file, body))
return file
}
+3
View File
@@ -0,0 +1,3 @@
// Phase 5 self-improvement primitives (#135 telemetry, #136 distillation).
export * from "./distill.ts"
export * from "./telemetry.ts"
+528
View File
@@ -0,0 +1,528 @@
// Read-only telemetry harvester over factory runs and eval results (#135).
// It aggregates what runs already record — factory state (phases, replans,
// halts, spend), gate summaries, audit records and the hash-chained audit
// log — plus eval aggregates, into one normalized, versioned dataset for the
// distiller (#136). It never writes to the harvested roots and never repairs
// a broken audit chain: a break is reported in `auditChain`.
import { readdir, readFile, stat } from "node:fs/promises"
import path from "node:path"
import { verifyAuditChain } from "../audit/chain.ts"
import { type FactoryState, FactoryStateSchema } from "../factory/state.ts"
import { stateDir as defaultStateDir, factoryLayout } from "../layout.ts"
import type { AuditEntry } from "../schema/audit.ts"
import { AuditRecordSchema } from "../schema/codeaudit.ts"
import {
type AuditFindingCount,
type ChainTrailEntry,
type EvalTelemetry,
type FailureEntry,
type GateRejection,
type RunStateSeal,
type RunTelemetry,
TELEMETRY_VERSION,
type Telemetry,
TelemetrySchema,
} from "../schema/improve.ts"
import type { SidecarProblem } from "../trust/sidecar.ts"
import { verifyEngineFile } from "../trust/sidecar.ts"
import { compareStrings } from "../util/compare.ts"
import { exists } from "../util/fs.ts"
/** A harvested run root plus its evals dir: everything here is read-only. */
export interface HarvestInput {
/** Project roots whose `.factory/` trees are harvested (run id = base name). */
readonly runs: readonly string[]
/** Eval result directories (`evals/results/<stamp>.json` aggregates). */
readonly evals: readonly string[]
}
const countBy = (keys: readonly string[]): Array<{ key: string; count: number }> => {
const counts = new Map<string, number>()
for (const key of keys) counts.set(key, (counts.get(key) ?? 0) + 1)
const entries = [...counts.entries()]
entries.sort(([a], [b]) => compareStrings(a, b))
return entries.map(([key, count]) => ({ key, count }))
}
async function readJson(file: string): Promise<unknown> {
try {
return JSON.parse(await readFile(file, "utf8"))
} catch {
return undefined
}
}
/** Every gate summary under `.factory/runs/`, deepest first in stable order. */
async function gateSummaries(dir: string): Promise<string[]> {
let entries: string[]
try {
entries = await readdir(dir)
} catch {
return []
}
const ordered = [...entries]
ordered.sort(compareStrings)
const found: string[] = []
const subdirs: string[] = []
for (const name of ordered) {
if (name === "summary.json") found.push(path.join(dir, name))
else subdirs.push(name)
}
const nested = await Promise.all(
subdirs.map(async (name) => {
const full = path.join(dir, name)
const info = await stat(full).catch(() => undefined)
if (info?.isDirectory() !== true) return []
return gateSummaries(full)
}),
)
for (const group of nested) found.push(...group)
return found
}
interface GateFinding {
readonly rule: string
readonly message?: string
readonly file?: string
readonly line?: number
}
/** Every finding with a rule in one gate summary, in file order. */
const findingsFromSummary = (parsed: unknown): GateFinding[] => {
if (typeof parsed !== "object" || parsed === null) return []
const findings = (parsed as { findings?: unknown }).findings
if (!Array.isArray(findings)) return []
const out: GateFinding[] = []
for (const finding of findings) {
if (typeof finding !== "object" || finding === null) continue
const record = finding as { rule?: unknown; message?: unknown; file?: unknown; line?: unknown }
if (typeof record.rule !== "string" || !record.rule) continue
const entry: { rule: string; message?: string; file?: string; line?: number } = { rule: record.rule }
if (typeof record.message === "string" && record.message) entry.message = record.message
if (typeof record.file === "string" && record.file) entry.file = record.file
if (typeof record.line === "number" && Number.isInteger(record.line)) entry.line = record.line
out.push(entry)
}
return out
}
/** Human reason for a gate finding: its message plus the file location. */
const gateReason = (finding: GateFinding): string => {
const where = finding.file ? ` (${finding.file}${finding.line !== undefined ? `:${finding.line}` : ""})` : ""
return `${finding.message ?? finding.rule}${where}`
}
/** Gate findings across every gate summary under `.factory/runs/`, counted by rule. */
async function harvestGateRejections(root: string): Promise<{ rejections: GateRejection[]; findings: GateFinding[] }> {
const summaries = await gateSummaries(factoryLayout(root).runs)
const parsed = await Promise.all(summaries.map((file) => readJson(file)))
const counts = new Map<string, { count: number; logs: Set<string> }>()
const findings: GateFinding[] = []
for (let index = 0; index < summaries.length; index++) {
const rel = path.relative(root, summaries[index]!)
for (const finding of findingsFromSummary(parsed[index])) {
findings.push(finding)
let entry = counts.get(finding.rule)
if (!entry) {
entry = { count: 0, logs: new Set() }
counts.set(finding.rule, entry)
}
entry.count += 1
entry.logs.add(rel)
}
}
return {
rejections: countBy([...counts.keys()]).map(({ key }) => {
const entry = counts.get(key)!
const logs = [...entry.logs]
logs.sort(compareStrings)
return { rule: key, count: entry.count, logs }
}),
findings,
}
}
interface HarvestedAuditFinding {
readonly seat: string
readonly kind: string
readonly severity: string
readonly reason: string
}
/** Every witnessed finding in one audit records file, with its seat. */
function findingsFromRecords(raw: unknown): HarvestedAuditFinding[] {
if (!Array.isArray(raw)) return []
const out: HarvestedAuditFinding[] = []
for (const entry of raw) {
const parsed = AuditRecordSchema.safeParse(entry)
if (!parsed.success) continue
for (const finding of parsed.data.findings)
out.push({
seat: parsed.data.seat,
kind: finding.kind,
severity: finding.severity,
reason: finding.detail || finding.excerpt || finding.title,
})
}
return out
}
/** Code-audit records under `.factory/audits/`, counted by finding kind and severity. */
async function harvestAuditFindings(
root: string,
): Promise<{ counts: AuditFindingCount[]; findings: HarvestedAuditFinding[] }> {
const auditsDir = factoryLayout(root).audits
let ids: string[]
try {
ids = await readdir(auditsDir)
} catch {
return { counts: [], findings: [] }
}
const ordered = [...ids]
ordered.sort(compareStrings)
const raws = await Promise.all(ordered.map((id) => readJson(path.join(auditsDir, id, "records.json"))))
const sources = new Map<string, Set<string>>()
const findings: HarvestedAuditFinding[] = []
for (let index = 0; index < ordered.length; index++) {
const rel = path.relative(root, path.join(auditsDir, ordered[index]!, "records.json"))
for (const finding of findingsFromRecords(raws[index])) {
findings.push(finding)
const key = `${finding.kind}/${finding.severity}`
let set = sources.get(key)
if (!set) {
set = new Set()
sources.set(key, set)
}
set.add(rel)
}
}
const tallies = new Map<string, number>()
for (const finding of findings) {
const key = `${finding.kind}/${finding.severity}`
tallies.set(key, (tallies.get(key) ?? 0) + 1)
}
const orderedKeys = [...tallies.keys()]
orderedKeys.sort(compareStrings)
const counts = orderedKeys.map((key) => {
const slash = key.indexOf("/")
const list = [...sources.get(key)!]
list.sort(compareStrings)
return { kind: key.slice(0, slash), severity: key.slice(slash + 1), count: tallies.get(key)!, sources: list }
})
return { counts, findings }
}
/** Why the run-state seal does not hold, in the machine's words (short form). */
const sealMessage = (problem: SidecarProblem): string =>
problem === "missing sidecar"
? "run state has no sidecar seal: it was not written by this engine; a human re-signs it with `es reseal --sign` after review"
: problem === "signature mismatch"
? "run state sidecar mismatch: edited outside the engine; a human re-signs it with `es reseal --sign` after review"
: "engine key unavailable here: the run state seal cannot be verified from this shell"
/**
* The run state through the same sealed reader the factory machine uses
* (S3.2, #135): a missing or forged seal is reported, and the run's
* state-derived content is not trusted. Gate summaries, audit records and
* the audit chain are harvested independently (they are not engine-sealed).
*/
async function readRunState(
root: string,
stateDir?: string,
): Promise<{ state: FactoryState | undefined; seal: RunStateSeal }> {
const file = factoryLayout(root).state
const raw = await readJson(file)
if (raw === undefined) {
if (!(await exists(file)))
return { state: undefined, seal: { valid: false, error: "missing state file: no run was recorded here" } }
const problem = await verifyEngineFile(file, stateDir ?? defaultStateDir())
if (problem) return { state: undefined, seal: { valid: false, error: sealMessage(problem) } }
return { state: undefined, seal: { valid: true } }
}
const problem = await verifyEngineFile(file, stateDir ?? defaultStateDir())
if (problem) return { state: undefined, seal: { valid: false, error: sealMessage(problem) } }
const parsed = FactoryStateSchema.safeParse(raw)
if (!parsed.success) return { state: undefined, seal: { valid: true } }
return { state: parsed.data, seal: { valid: true } }
}
/** The audit-chain trail: sequence, hash and action per entry, for evidence links. */
const trailOf = (entries: readonly AuditEntry[]): ChainTrailEntry[] =>
entries.map((entry) => {
const phase = typeof entry.payload.phase === "string" && entry.payload.phase ? entry.payload.phase : undefined
const reason = typeof entry.payload.reason === "string" && entry.payload.reason ? entry.payload.reason : undefined
return {
seq: entry.seq,
hash: entry.hash,
action: entry.action,
...(phase ? { phase } : {}),
...(reason ? { reason } : {}),
}
})
/** A chain actor (`agent:<seat>`, `system`, …) as a seat name. */
const actorSeat = (actor: string): string => (actor.startsWith("agent:") ? actor.slice("agent:".length) : actor)
/**
* Per-phase failure entries (S3.4): chain `qa.fail` / `phase.replan` events
* land on their named phase; run-level gate and audit findings land on every
* failed, replanning or replanned phase (a green run's warnings are not
* failures). Deterministic: chain order, then summary order, then records.
*/
function phaseFailures(
state: FactoryState,
entries: readonly AuditEntry[],
gate: readonly GateFinding[],
audit: readonly HarvestedAuditFinding[],
): Map<string, FailureEntry[]> {
const byPhase = new Map<string, FailureEntry[]>(state.phases.map((phase) => [phase.id, []]))
for (const entry of entries) {
const payload = entry.payload
const phase = typeof payload.phase === "string" ? payload.phase : undefined
if (!phase || !byPhase.has(phase)) continue
if (entry.action === "qa.fail") {
const reason = typeof payload.reason === "string" && payload.reason ? payload.reason : "qa failed"
byPhase.get(phase)!.push({ seat: actorSeat(entry.actor), kind: "qa", reason })
} else if (entry.action === "phase.replan") {
const attempt = typeof payload.attempt === "number" ? `replan attempt ${payload.attempt}` : "replanned"
const reason = typeof payload.reason === "string" && payload.reason ? payload.reason : attempt
byPhase.get(phase)!.push({ seat: actorSeat(entry.actor), kind: "replan", reason })
}
}
const failed = state.phases
.filter((phase) => phase.status === "failed" || phase.status === "replanning" || phase.replanned)
.map((phase) => phase.id)
if (failed.length > 0) {
for (const finding of gate)
for (const id of failed) byPhase.get(id)!.push({ kind: "gate", rule: finding.rule, reason: gateReason(finding) })
for (const finding of audit)
for (const id of failed)
byPhase.get(id)!.push({
seat: finding.seat,
kind: "audit",
category: `${finding.kind}/${finding.severity}`,
reason: finding.reason,
})
}
return byPhase
}
const phaseTelemetry = (
state: FactoryState | undefined,
entries: readonly AuditEntry[],
gate: readonly GateFinding[],
audit: readonly HarvestedAuditFinding[],
) => {
if (!state) return []
const failures = phaseFailures(state, entries, gate, audit)
const phases = state.phases.map((phase) => ({
id: phase.id,
status: phase.status,
attempts: phase.status === "pending" ? 0 : phase.failures + 1,
replans: (phase.replanned ? 1 : 0) + phase.history.filter((event) => event.event.includes("replan")).length,
failures: failures.get(phase.id) ?? [],
}))
phases.sort((a, b) => compareStrings(a.id, b.id))
return phases
}
/**
* Harvest one run root. Missing state is tolerated (an empty phase list, zero
* spend, stage "unknown") so half-written runs still produce a record; an
* unsealed state is reported in `seal` and its content is not trusted; a
* broken audit chain is reported, never repaired.
*/
export async function harvestRun(root: string, options: { stateDir?: string } = {}): Promise<RunTelemetry> {
const { state, seal } = await readRunState(root, options.stateDir)
const chain = await verifyAuditChain(root)
const [{ rejections: gateRejections, findings: gateFindings }, { counts: auditFindings, findings: harvestedAudit }] =
await Promise.all([harvestGateRejections(root), harvestAuditFindings(root)])
const halt = state?.halt
return {
id: path.basename(path.resolve(root)),
mode: state?.mode ?? "unknown",
stage: state?.stage ?? "unknown",
...(halt ? { halt: { reason: halt.reason, from: halt.from } } : {}),
spend: {
usd: state?.spend.usd ?? 0,
estimated: state?.spend.estimated ?? false,
events: state?.spend.events ?? 0,
},
phases: phaseTelemetry(state, chain.entries, gateFindings, harvestedAudit),
gateRejections,
auditFindings,
auditChain: {
entries: chain.entries.length,
valid: chain.valid,
...(chain.valid ? {} : { error: chain.error ?? "audit chain invalid" }),
trail: trailOf(chain.entries),
},
seal,
}
}
const stepsOf = (entry: Record<string, unknown>): number => {
if (typeof entry.steps !== "number") return 0
if (!Number.isInteger(entry.steps)) return 0
if (entry.steps < 0) return 0
return entry.steps
}
const modelOf = (entry: Record<string, unknown>, fallback: string | undefined): string | undefined => {
if (typeof entry.model === "string") return entry.model
return fallback
}
const failuresOf = (entry: Record<string, unknown>): string[] => {
if (!Array.isArray(entry.failures)) return []
return entry.failures.filter((item): item is string => typeof item === "string")
}
/** One eval aggregate entry, or undefined when it is not a result row. */
function toEvalEntry(result: unknown, model: string | undefined, source: string): EvalTelemetry | undefined {
if (typeof result !== "object" || result === null) return undefined
const entry = result as Record<string, unknown>
if (typeof entry.id !== "string") return undefined
if (typeof entry.pass !== "boolean") return undefined
const telemetry: EvalTelemetry = {
case: entry.id,
pass: entry.pass,
failures: failuresOf(entry),
steps: stepsOf(entry),
source,
}
const resolved = modelOf(entry, model)
if (resolved !== undefined) telemetry.model = resolved
if (entry.modelLimited === true) telemetry.modelLimited = true
return telemetry
}
/** One eval aggregate file (`{results: [{id, pass, failures, steps, ...}]}`), as `scripts/evals.ts` writes. */
function evalsFromAggregate(raw: unknown, model: string | undefined, source: string): EvalTelemetry[] {
if (typeof raw !== "object" || raw === null) return []
const results = (raw as { results?: unknown }).results
if (!Array.isArray(results)) return []
const out: EvalTelemetry[] = []
for (const result of results) {
const entry = toEvalEntry(result, model, source)
if (entry !== undefined) out.push(entry)
}
return out
}
const aggregateModel = (raw: unknown): string | undefined => {
if (typeof raw !== "object" || raw === null) return undefined
const model = (raw as { model?: unknown }).model
if (typeof model === "string") return model
return undefined
}
/** Harvest eval telemetry from result directories (aggregate `<stamp>.json` files). */
export async function harvestEvals(dir: string): Promise<EvalTelemetry[]> {
let names: string[]
try {
names = await readdir(dir)
} catch {
return []
}
const ordered = [...names]
ordered.sort(compareStrings)
const files = ordered.filter((name) => name.endsWith(".json"))
const raws = await Promise.all(files.map((name) => readJson(path.join(dir, name))))
const out: EvalTelemetry[] = []
for (let index = 0; index < files.length; index++) {
const raw = raws[index]
if (raw === undefined) continue
out.push(...evalsFromAggregate(raw, aggregateModel(raw), files[index]!))
}
out.sort((a, b) => compareStrings(a.case, b.case))
return out
}
// ------------------------------------------------------------------ scrub
//
// Telemetry stays local (#135: no network upload), but it must still carry
// no secrets: env-like values are replaced, and paths under Epistemic Swarm's
// private state dir are collapsed to `<state-dir>`.
const SECRET_KEY =
/(token|secret|passwd|password|api[-_]?key|auth|credential|private[-_]?key|passphrase|session[-_]?key)/i
const SCRUBBED = "[redacted]"
/**
* `NAME=value` / `NAME: value` whose NAME matches SECRET_KEY: only the value
* is replaced, so `GITHUB_TOKEN=ghp_abc` keeps its shape as
* `GITHUB_TOKEN=[redacted]` (S3.1, #135).
*/
const SECRET_PAIR = new RegExp(
`([A-Za-z0-9_.-]*${SECRET_KEY.source}[A-Za-z0-9_.-]*\\s*[:=]\\s*)(['"]?)([^\\s'";,]+)`,
"gi",
)
/** `Bearer <token>`: the scheme stays, the credential goes. */
const BEARER_TOKEN = /\b([Bb][Ee][Aa][Rr][Ee][Rr]\s+)[A-Za-z0-9\-._~+/=]+/g
/**
* Known token shapes standing bare in prose. Bodies take `-` and `_` too:
* `sk-proj-…`/`sk-svcacct-…` keys carry inner dashes, and a body that stops
* at the first one would redact only the prefix and leak the rest.
*/
const TOKEN_SHAPE =
/\b(?:github_pat_[A-Za-z0-9_]+|gh[oprsu]_[A-Za-z0-9_]+|sk-[A-Za-z0-9_-]+|xox[abprs]-[A-Za-z0-9-]+|AKIA[0-9A-Z]{16})\b/g
/** PEM private-key blocks, whole. */
const PEM_BLOCK = /-----BEGIN [^-]*PRIVATE KEY[^-]*-----[\s\S]*?-----END [^-]*PRIVATE KEY[^-]*-----/g
/** Replace secret-looking values and private-state-dir paths, preserving shape. */
export function scrubSecrets<T>(value: T, options: { stateDir?: string } = {}): T {
const dir = options.stateDir ?? defaultStateDir()
const scrubString = (text: string): string => {
// Bearer first: `Authorization: Bearer <tok>` would otherwise match the
// pair rule on `Authorization` and leave the credential behind.
let out = text.replace(PEM_BLOCK, SCRUBBED)
out = out.replace(BEARER_TOKEN, `$1${SCRUBBED}`)
out = out.replace(SECRET_PAIR, `$1$2${SCRUBBED}`)
out = out.replace(TOKEN_SHAPE, SCRUBBED)
return dir && out.includes(dir) ? out.split(dir).join("<state-dir>") : out
}
const walk = (node: unknown, key?: string): unknown => {
if (typeof node === "string") {
if (key !== undefined && SECRET_KEY.test(key)) return SCRUBBED
return scrubString(node)
}
if (Array.isArray(node)) return node.map((item) => walk(item, key))
if (node && typeof node === "object") {
const out: Record<string, unknown> = {}
for (const [child, item] of Object.entries(node as Record<string, unknown>)) out[child] = walk(item, child)
return out
}
return node
}
return walk(value) as T
}
/** Harvest every run and eval dir into one validated, scrubbed dataset. */
export async function harvestTelemetry(
input: HarvestInput,
options: { stateDir?: string; harvestedAt?: string } = {},
): Promise<Telemetry> {
const harvested = await Promise.all(
input.runs.map((root) => harvestRun(root, { ...(options.stateDir ? { stateDir: options.stateDir } : {}) })),
)
harvested.sort((a, b) => compareStrings(a.id, b.id))
const evalGroups = await Promise.all(input.evals.map((dir) => harvestEvals(dir)))
const evals: EvalTelemetry[] = []
for (const group of evalGroups) evals.push(...group)
evals.sort((a, b) => compareStrings(a.case, b.case))
return TelemetrySchema.parse(
scrubSecrets(
{
version: TELEMETRY_VERSION,
harvestedAt: options.harvestedAt ?? new Date().toISOString(),
runs: harvested,
evals,
},
{ ...(options.stateDir ? { stateDir: options.stateDir } : {}) },
),
)
}
+3
View File
@@ -15,6 +15,7 @@ export * from "./factory/index.ts"
export * from "./gates/index.ts"
export * from "./harvest/index.ts"
export * from "./hooks/index.ts"
export * from "./improve/index.ts"
export * from "./layout.ts"
export * from "./lsp/index.ts"
export * from "./ops/tools.ts"
@@ -24,6 +25,8 @@ export * from "./schema/index.ts"
export * from "./scout/index.ts"
export * from "./structure/index.ts"
export * from "./trust/index.ts"
export * from "./util/args.ts"
export * from "./util/compare.ts"
export * from "./util/fs.ts"
export * from "./util/glob.ts"
export * from "./util/hash.ts"
+19
View File
@@ -43,6 +43,13 @@ export function factoryLayout(root: string) {
brainstormScores: path.join(dir, "brainstorm", "scores.json"),
brainstormResult: path.join(dir, "brainstorm", "brainstorm.json"),
brainstormReport: path.join(dir, "brainstorm", "BRAINSTORM.md"),
brainstormRuns: path.join(dir, "brainstorm", "runs"),
brainstormRun: (runId: string) => path.join(dir, "brainstorm", "runs", runId),
brainstormRunPlan: (runId: string) => path.join(dir, "brainstorm", "runs", runId, "plan.json"),
brainstormRunIdeas: (runId: string) => path.join(dir, "brainstorm", "runs", runId, "ideas.json"),
brainstormRunScores: (runId: string) => path.join(dir, "brainstorm", "runs", runId, "scores.json"),
brainstormRunResult: (runId: string) => path.join(dir, "brainstorm", "runs", runId, "brainstorm.json"),
brainstormRunReport: (runId: string) => path.join(dir, "brainstorm", "runs", runId, "BRAINSTORM.md"),
harvest: path.join(dir, "harvest"),
harvestPlan: path.join(dir, "harvest", "plan.json"),
harvestMatrix: path.join(dir, "harvest", "matrix.json"),
@@ -50,6 +57,18 @@ export function factoryLayout(root: string) {
harvestReport: path.join(dir, "harvest", "HARVEST.md"),
harvestVendorPlan: path.join(dir, "harvest", "VENDOR-PLAN.md"),
harvestCleanRoom: path.join(dir, "harvest", "clean-room"),
harvestRuns: path.join(dir, "harvest", "runs"),
harvestRun: (runId: string) => path.join(dir, "harvest", "runs", runId),
harvestRunPlan: (runId: string) => path.join(dir, "harvest", "runs", runId, "plan.json"),
harvestRunMatrix: (runId: string) => path.join(dir, "harvest", "runs", runId, "matrix.json"),
harvestRunResult: (runId: string) => path.join(dir, "harvest", "runs", runId, "harvest.json"),
harvestRunReport: (runId: string) => path.join(dir, "harvest", "runs", runId, "HARVEST.md"),
harvestRunVendorPlan: (runId: string) => path.join(dir, "harvest", "runs", runId, "VENDOR-PLAN.md"),
harvestRunCleanRoom: (runId: string) => path.join(dir, "harvest", "runs", runId, "clean-room"),
harvestRunProfile: (runId: string, id: string) => path.join(dir, "harvest", "runs", runId, id, "profile.json"),
improve: path.join(dir, "improve"),
improveProposals: path.join(dir, "improve", "proposals"),
improveProposal: (id: string) => path.join(dir, "improve", "proposals", id),
design: path.join(dir, "design"),
designInterview: path.join(dir, "design", "interview.json"),
designTokens: path.join(dir, "design", "tokens.json"),
+4 -3
View File
@@ -112,8 +112,8 @@ export function esTools(ops: OpsContext): EsToolDef[] {
input: object({ stage: { type: "string", enum: ["frontier", "spec"] }, note: { type: "string" } }, ["stage"]),
execute: async ({ stage, note }, context) => {
const seat = seatOf(context.agent)
if (!seat || !["factory", "grill", "manager"].includes(seat))
throw new ToolRefusal("Only the factory, grill or manager seat may request approvals.")
if (!seat || !["factory", "grill"].includes(seat))
throw new ToolRefusal("Only the factory or grill seat may request approvals.")
const subject = await approvalSubject(root, stage).catch((error: Error) => {
const missing =
/missing artifact.*frontier|not settled yet|frontier/i.test(error.message) && stage === "frontier"
@@ -137,7 +137,8 @@ export function esTools(ops: OpsContext): EsToolDef[] {
},
{
name: "es_research_complete",
description: "Factory only: finish RESEARCH once .factory/research/REPORT.md is written; moves to SPEC.",
description:
"Factory or deep-researcher: finish RESEARCH once .factory/research/REPORT.md is written; a build run moves to SPEC, a research run to DONE.",
input: object(),
execute: async (_input, context) => factorySummary(await factory.completeResearch(context.agent)),
},
@@ -0,0 +1,262 @@
// Ordered source-backend failover (#106). A `SourceBackend` can search
// and/or fetch; the chain tries them in config order, skipping backends
// without credentials and cooling down backends that just failed. A
// `blocked` refusal is a safety decision (SSRF/policy): it stops the chain,
// never failing over around it. Everything a backend returns still goes
// through the sealed cache in research/ops.ts; this module never touches it.
import {
braveProvider,
type FetchedPage,
fetchPage,
firecrawlProvider,
type HostResolver,
type SearchProvider,
type SearchResult,
searxngProvider,
} from "../providers.ts"
import { scraperSwarmBackend } from "./scraper-swarm.ts"
export type BackendErrorKind = "unavailable" | "auth" | "rate_limited" | "blocked" | "upstream" | "timeout"
/** A classified backend failure. Only `blocked` stops the chain. */
export class BackendError extends Error {
readonly kind: BackendErrorKind
readonly retryAfterMs?: number
readonly status?: number
constructor(kind: BackendErrorKind, message: string, options: { retryAfterMs?: number; status?: number } = {}) {
super(message)
this.name = "BackendError"
this.kind = kind
if (options.retryAfterMs !== undefined) this.retryAfterMs = options.retryAfterMs
if (options.status !== undefined) this.status = options.status
}
}
export interface BackendCapabilities {
readonly search: boolean
readonly fetch: boolean
}
export interface BackendSearchOptions {
readonly limit?: number
readonly signal?: AbortSignal
readonly fetch?: typeof fetch
readonly env?: NodeJS.ProcessEnv
}
export interface BackendFetchOptions {
readonly signal?: AbortSignal
readonly fetch?: typeof fetch
readonly env?: NodeJS.ProcessEnv
/** DNS resolver for fetchPage's SSRF guard (tests inject a fake); default resolves for real. */
readonly resolveHost?: HostResolver
}
export interface SourceBackend {
readonly id: string
readonly capabilities: BackendCapabilities
available(env?: NodeJS.ProcessEnv): boolean
search?(query: string, options: BackendSearchOptions): Promise<SearchResult[]>
fetch?(url: string, options: BackendFetchOptions): Promise<FetchedPage>
}
const searchBackend = (provider: SearchProvider): SourceBackend => ({
id: provider.id,
capabilities: { search: true, fetch: false },
available: (env) => provider.available(env),
search: (query, options) => provider.search(query, options),
})
export const braveBackend: SourceBackend = searchBackend(braveProvider)
export const firecrawlBackend: SourceBackend = searchBackend(firecrawlProvider)
export const searxngBackend: SourceBackend = searchBackend(searxngProvider)
/** Today's page fetcher as a backend: always available, fetch-only. */
export const directBackend: SourceBackend = {
id: "direct",
capabilities: { search: false, fetch: true },
available: () => true,
fetch: (url, options) => fetchPage(url, options),
}
/**
* The built-in registry, built lazily: scraper-swarm.ts imports this module
* for its errors, so no registry entry may be read at evaluation time.
*/
function builtinBackends(): Record<string, SourceBackend> {
return {
brave: braveBackend,
firecrawl: firecrawlBackend,
searxng: searxngBackend,
direct: directBackend,
"scraper-swarm": scraperSwarmBackend,
}
}
/** Look up one backend by id; `extra` (tests, custom gateways) adds ids. */
export function getBackend(id: string, extra?: readonly SourceBackend[]): SourceBackend | undefined {
return extra?.find((backend) => backend.id === id) ?? builtinBackends()[id]
}
/** Today's selection order: the self-hosted gateway first when configured, then today's providers. */
export const DEFAULT_BACKEND_ORDER: readonly string[] = ["scraper-swarm", "brave", "firecrawl", "searxng", "direct"]
/**
* Resolve the backend order: explicit `research.backends` wins, a legacy
* `searchProvider` maps to a one-element search order with direct fetch,
* and unset config keeps today's provider order.
*/
export function resolveBackendOrder(options: { backends?: readonly string[]; provider?: string }): string[] {
if (options.backends?.length) return [...options.backends]
if (options.provider) return [options.provider, "direct"]
return [...DEFAULT_BACKEND_ORDER]
}
/** Providers whose cached entries hold the whole page (legacy ids included). */
export function isFetchedSnapshot(providerId: string, extra?: readonly SourceBackend[]): boolean {
return (
providerId === "fetch" || providerId === "webfetch" || getBackend(providerId, extra)?.capabilities.fetch === true
)
}
/** Parse a Retry-After header (delay seconds or an HTTP date) to milliseconds. */
export function parseRetryAfterMs(value: string | null | undefined, now: number = Date.now()): number | undefined {
if (value === null || value === undefined || value.trim() === "") return undefined
const seconds = Number(value)
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000
const at = Date.parse(value)
if (!Number.isNaN(at)) return Math.max(0, at - now)
return undefined
}
/** Classify any thrown value into a chain decision. Unknown errors fail over; only `blocked` stops. */
export function toBackendError(error: unknown): BackendError {
if (error instanceof BackendError) return error
if (error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError"))
return new BackendError("timeout", error.message || "The backend request timed out")
if (typeof error === "object" && error !== null) {
const record = error as { code?: unknown; status?: unknown; retryAfterMs?: unknown; message?: unknown }
if (record.code === "blocked")
return new BackendError("blocked", typeof record.message === "string" ? record.message : "Blocked by policy")
if (typeof record.status === "number") {
const message = typeof record.message === "string" ? record.message : `HTTP ${record.status}`
if (record.status === 429)
return new BackendError("rate_limited", message, {
...(typeof record.retryAfterMs === "number" ? { retryAfterMs: record.retryAfterMs } : {}),
status: record.status,
})
if (record.status === 401 || record.status === 403)
return new BackendError("auth", message, { status: record.status })
return new BackendError("upstream", message, { status: record.status })
}
}
if (error instanceof TypeError) return new BackendError("unavailable", error.message || "The backend is unreachable")
return new BackendError("upstream", error instanceof Error ? error.message : "The backend request failed")
}
/** How long a failure keeps a backend out of the chain. */
export function backoffFor(error: BackendError): number {
if (error.kind === "rate_limited") return error.retryAfterMs ?? 60_000
return 30_000
}
export interface BackendAttempt {
readonly backend: string
readonly ok: boolean
readonly kind?: BackendErrorKind
}
export interface ChainSuccess<T> {
readonly value: T
readonly backend: string
readonly attempts: BackendAttempt[]
}
export interface ChainOptions {
/** Clock for cooldowns (tests); default Date.now. */
readonly now?: () => number
/**
* Shared cooldown state (backend id → cooldown expiry and cause). Defaults
* to a module-level map; the research tools keep one per plugin process so
* a down backend is not retried on every call.
*/
readonly cooldowns?: Map<string, { until: number; kind: BackendErrorKind }>
}
const sharedCooldowns = new Map<string, { until: number; kind: BackendErrorKind }>()
const NO_BACKEND =
"No search provider is configured: set BRAVE_API_KEY, FIRECRAWL_API_KEY or SEARXNG_URL (or fetch known URLs directly)."
async function runChain<T>(
backends: readonly SourceBackend[],
kind: "search" | "fetch",
invoke: (backend: SourceBackend) => Promise<T>,
options?: ChainOptions,
): Promise<ChainSuccess<T>> {
const now = options?.now ?? Date.now
const cooldowns = options?.cooldowns ?? sharedCooldowns
const attempts: BackendAttempt[] = []
let lastError: BackendError | undefined
for (const backend of backends) {
if (kind === "search" && (!backend.capabilities.search || !backend.search)) continue
if (kind === "fetch" && (!backend.capabilities.fetch || !backend.fetch)) continue
const cooling = cooldowns.get(backend.id)
if (cooling && cooling.until > now()) {
attempts.push({ backend: backend.id, ok: false, kind: cooling.kind })
continue
}
try {
const value = await invoke(backend)
attempts.push({ backend: backend.id, ok: true })
return { value, backend: backend.id, attempts }
} catch (error) {
const classified = toBackendError(error)
lastError = classified
attempts.push({ backend: backend.id, ok: false, kind: classified.kind })
// A safety refusal is never routed around: stop the chain here.
if (classified.kind === "blocked") throw classified
cooldowns.set(backend.id, { until: now() + backoffFor(classified), kind: classified.kind })
}
}
throw lastError ?? new BackendError("unavailable", NO_BACKEND)
}
/** Search through the chain in order; availability is checked per call. */
export async function runSearchChain(
backends: readonly SourceBackend[],
query: string,
search: BackendSearchOptions,
options?: ChainOptions,
): Promise<ChainSuccess<SearchResult[]>> {
const unavailable = backends
.filter((backend) => !backend.available(search.env))
.map((backend) => ({ backend: backend.id, ok: false as const, kind: "unavailable" as BackendErrorKind }))
const result = await runChain(
backends.filter((backend) => backend.available(search.env)),
"search",
(backend) => backend.search!(query, search),
options,
)
return { ...result, attempts: [...unavailable, ...result.attempts] }
}
/** Fetch through the chain in order; availability is checked per call. */
export async function runFetchChain(
backends: readonly SourceBackend[],
url: string,
options: BackendFetchOptions & ChainOptions & { env?: NodeJS.ProcessEnv },
): Promise<ChainSuccess<FetchedPage>> {
const { now, cooldowns, env, ...fetchOptions } = options
const unavailable = backends
.filter((backend) => !backend.available(env))
.map((backend) => ({ backend: backend.id, ok: false as const, kind: "unavailable" as BackendErrorKind }))
const result = await runChain(
backends.filter((backend) => backend.available(env)),
"fetch",
(backend) => backend.fetch!(url, { ...fetchOptions, ...(env ? { env } : {}) }),
{ now, cooldowns },
)
return { ...result, attempts: [...unavailable, ...result.attempts] }
}
@@ -0,0 +1,200 @@
// Scraper-Swarm gateway backend (#107): the user's self-hosted
// search-and-scrape gateway behind one authenticated JSON-RPC endpoint
// (POST /mcp). ADR 0001 keeps the gateway its own repo; this client speaks
// the versioned protocol only, and the contract tests replay the vendored
// golden fixtures without running the stack.
//
// The token comes only from ES_SCRAPER_SWARM_TOKEN (masked in seat sandboxes
// via SECRET_ENV) and never leaves the Authorization header: it is never
// written to .factory/, logs or error messages.
import { sha256 } from "../../util/hash.ts"
import type { FetchedPage, SearchResult } from "../providers.ts"
import {
BackendError,
type BackendFetchOptions,
type BackendSearchOptions,
parseRetryAfterMs,
type SourceBackend,
} from "./index.ts"
/** The gateway contract major this client speaks (fixtures pin the same). */
export const SCRAPER_SWARM_CONTRACT_VERSION = 1 as const
export const SCRAPER_SWARM_BACKEND_ID = "scraper-swarm" as const
export const SCRAPER_SWARM_URL_ENV = "ES_SCRAPER_SWARM_URL" as const
export const SCRAPER_SWARM_TOKEN_ENV = "ES_SCRAPER_SWARM_TOKEN" as const
export interface ScraperSwarmConfig {
readonly url: string
readonly token: string
readonly fetch?: typeof fetch
readonly signal?: AbortSignal
}
/** Read the gateway config from the environment; undefined unless fully configured. */
export function scraperSwarmConfig(env: NodeJS.ProcessEnv = process.env): ScraperSwarmConfig | undefined {
const url = env[SCRAPER_SWARM_URL_ENV]
const token = env[SCRAPER_SWARM_TOKEN_ENV]
if (!url || !token) return undefined
return { url, token }
}
interface JsonRpcOk {
readonly id: unknown
readonly result: {
readonly content?: ReadonlyArray<{ readonly text?: string }>
readonly structuredContent?: Record<string, any>
readonly isError?: boolean
}
}
interface JsonRpcError {
readonly id: unknown
readonly error: { readonly code: number; readonly message: string }
}
const contractMajor = (header: string | null): number | undefined => {
if (!header) return undefined
const major = Number(header.trim().split(".")[0])
return Number.isInteger(major) && major >= 0 ? major : undefined
}
export class ScraperSwarmClient {
private readonly endpoint: string
private readonly token: string
private readonly doFetch: typeof fetch
private readonly signal?: AbortSignal
private id = 0
constructor(config: ScraperSwarmConfig) {
this.endpoint = config.url.replace(/\/+$/, "")
this.token = config.token
this.doFetch = config.fetch ?? fetch
if (config.signal) this.signal = config.signal
}
private async call(tool: "web_search" | "fetch_page", args: Record<string, unknown>): Promise<JsonRpcOk["result"]> {
this.id += 1
let response: Response
try {
response = await this.doFetch(`${this.endpoint}/mcp`, {
method: "POST",
headers: { "content-type": "application/json", Authorization: `Bearer ${this.token}` },
body: JSON.stringify({
jsonrpc: "2.0",
id: this.id,
method: "tools/call",
params: { name: tool, arguments: args },
}),
...(this.signal ? { signal: this.signal } : {}),
})
} catch {
// Never let the token leak into a network error message.
throw new BackendError("unavailable", "Scraper-Swarm gateway is unreachable")
}
if (response.status === 401 || response.status === 403)
throw new BackendError("auth", `Scraper-Swarm gateway refused the call: HTTP ${response.status}`)
if (response.status === 429)
throw new BackendError("rate_limited", "Scraper-Swarm gateway rate limit exceeded", {
...(parseRetryAfterMs(response.headers.get("retry-after")) !== undefined
? { retryAfterMs: parseRetryAfterMs(response.headers.get("retry-after"))! }
: {}),
status: response.status,
})
if (!response.ok) throw new BackendError("upstream", `Scraper-Swarm gateway failed: HTTP ${response.status}`)
const major = contractMajor(response.headers.get("x-swarm-contract"))
if (major !== SCRAPER_SWARM_CONTRACT_VERSION)
throw new BackendError(
"upstream",
`Scraper-Swarm gateway speaks contract ${response.headers.get("x-swarm-contract") ?? "unknown"}; this client speaks v${SCRAPER_SWARM_CONTRACT_VERSION} — re-vendor the fixtures and update the client`,
)
const body = (await response.json().catch(() => undefined)) as JsonRpcOk | JsonRpcError | undefined
if (!body || typeof body !== "object" || !("jsonrpc" in body)) {
// A FastAPI error body ({"detail"}) on an unexpected status lands here.
const detail = (body as { detail?: unknown } | undefined)?.detail
throw new BackendError(
"upstream",
typeof detail === "string"
? `Scraper-Swarm gateway: ${detail}`
: "Scraper-Swarm gateway returned no JSON-RPC body",
)
}
if ("error" in body)
throw new BackendError("upstream", `Scraper-Swarm gateway: ${body.error.message} (${body.error.code})`)
if (body.id !== this.id) throw new BackendError("upstream", "Scraper-Swarm gateway echoed the wrong JSON-RPC id")
const result = body.result
if (result.isError) {
const code = result.structuredContent?.code
const message = result.structuredContent?.message ?? result.content?.[0]?.text ?? "gateway tool error"
if (code === "ssrf_denied" || code === "blocked_by_policy")
throw new BackendError("blocked", `Scraper-Swarm gateway: ${message}`)
if (code === "upstream_timeout") throw new BackendError("timeout", `Scraper-Swarm gateway: ${message}`)
throw new BackendError("upstream", `Scraper-Swarm gateway: ${message}${code ? ` (${code})` : ""}`)
}
return result
}
async search(query: string, limit = 8): Promise<SearchResult[]> {
const result = await this.call("web_search", { query, limit })
const items = result.structuredContent?.results
if (!Array.isArray(items)) throw new BackendError("upstream", "Scraper-Swarm gateway returned no search results")
return items.flatMap((item): SearchResult[] => {
const url = typeof item?.url === "string" ? item.url : undefined
if (!url) return []
return [
{
url,
title: typeof item?.title === "string" && item.title ? item.title : url,
snippet: typeof item?.snippet === "string" ? item.snippet : "",
},
]
})
}
async fetchPage(url: string): Promise<FetchedPage> {
const result = await this.call("fetch_page", { url })
const page = result.structuredContent
const content = typeof page?.content === "string" ? page.content : undefined
if (!content) throw new BackendError("upstream", "Scraper-Swarm gateway returned no page content")
// Fail closed: the bytes must hash to the gateway's attested digest before anything caches them.
if (typeof page?.content_sha256 === "string" && sha256(content) !== page.content_sha256)
throw new BackendError("upstream", "Scraper-Swarm gateway content_sha256 mismatch: refusing the page")
const finalUrl = typeof page?.final_url === "string" && page.final_url ? page.final_url : url
const contentType =
typeof page?.content_type === "string" && page.content_type
? page.content_type
: page?.format === "markdown"
? "text/markdown"
: "text/plain"
return {
url: finalUrl,
...(typeof page?.title === "string" && page.title ? { title: page.title } : {}),
text: content,
contentType,
}
}
}
const clientFor = (options: BackendSearchOptions | BackendFetchOptions): ScraperSwarmClient | undefined => {
const fromEnv = scraperSwarmConfig(options.env ?? process.env)
if (!fromEnv) return undefined
return new ScraperSwarmClient({
...fromEnv,
...(options.fetch ? { fetch: options.fetch } : {}),
...(options.signal ? { signal: options.signal } : {}),
})
}
export const scraperSwarmBackend: SourceBackend = {
id: SCRAPER_SWARM_BACKEND_ID,
capabilities: { search: true, fetch: true },
available: (env = process.env) => scraperSwarmConfig(env) !== undefined,
search: async (query, options) => {
const client = clientFor(options)
if (!client) throw new BackendError("unavailable", "Scraper-Swarm gateway is not configured")
return client.search(query, options.limit ?? 8)
},
fetch: async (url, options) => {
const client = clientFor(options)
if (!client) throw new BackendError("unavailable", "Scraper-Swarm gateway is not configured")
return client.fetchPage(url)
},
}
+7
View File
@@ -8,6 +8,7 @@ import { factoryLayout } from "../layout.ts"
import { atomicWrite, readJson } from "../util/fs.ts"
import { sha256 } from "../util/hash.ts"
import { ensureEngineKey, readEngineKey, sealMeta, verifyMetaSeal } from "./seal.ts"
import { classifySourceTier } from "./tier.ts"
import { canonicalUrl } from "./url.ts"
export interface SourceMeta {
@@ -21,6 +22,8 @@ export interface SourceMeta {
readonly bytes: number
/** Engine HMAC seal (#52); present when written through a sealed cache. */
readonly seal?: string
/** Source-quality tier for domain-pack weights (#113); absent means __default__. Sealed with the rest. */
readonly tier?: string
}
export interface CachedSource {
@@ -74,11 +77,14 @@ export class SourceCache {
title?: string
provider: string
query?: string
/** Pack tierDomains override for classification; unset uses the built-in table (#113). */
tierDomains?: Record<string, string[]>
}): Promise<CachedSource> {
let text = normalizeSourceText(input.text)
if (Buffer.byteLength(text) > MAX_SOURCE_BYTES)
text = `${Buffer.from(text).subarray(0, MAX_SOURCE_BYTES).toString("utf8")}\n\n[truncated by epistemic-swarm at ${MAX_SOURCE_BYTES} bytes]`
const hash = sha256(text)
const tier = classifySourceTier(input.url, input.tierDomains)
const meta = await this.seal({
sha256: hash,
url: input.url,
@@ -87,6 +93,7 @@ export class SourceCache {
provider: input.provider,
...(input.query ? { query: input.query } : {}),
bytes: Buffer.byteLength(text),
...(tier ? { tier } : {}),
})
const file = path.join(this.dir, `${hash}.md`)
await atomicWrite(file, text)
+2
View File
@@ -1,4 +1,5 @@
export * from "./auditor.ts"
export * from "./backends/index.ts"
export * from "./cache.ts"
export * from "./html.ts"
export * from "./ops.ts"
@@ -6,4 +7,5 @@ export * from "./osv.ts"
export * from "./providers.ts"
export * from "./quote.ts"
export * from "./seal.ts"
export * from "./tier.ts"
export * from "./url.ts"
+85 -33
View File
@@ -2,13 +2,23 @@
// and audit (verify tags and quotes, optionally prune).
import { readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import { seatMayUse } from "../agents/registry.ts"
import { stateDir as defaultStateDir, factoryLayout } from "../layout.ts"
import type { EsToolDef } from "../ops/tools.ts"
import { ToolRefusal } from "../ops/tools.ts"
import type { EsConfig } from "../schema/config.ts"
import { evaluateWrite, type PolicyContext } from "../trust/policy.ts"
import { auditMarkdown, formatCoverage, pruneClaims } from "./auditor.ts"
import {
type BackendErrorKind,
getBackend,
isFetchedSnapshot,
resolveBackendOrder,
runFetchChain,
runSearchChain,
type SourceBackend,
} from "./backends/index.ts"
import { researchSourcesDir, SourceCache } from "./cache.ts"
import { fetchPage, type SearchProvider, selectProvider } from "./providers.ts"
import { isFresh } from "./url.ts"
export interface ResearchOpsContext {
@@ -25,16 +35,33 @@ export interface ResearchOpsContext {
readonly searchTimeoutS?: number
/** SearXNG instance (config research.searxngUrl); overrides SEARXNG_URL. */
readonly searxngUrl?: string
/** Pack tierDomains override for source classification at ingestion (#113). */
readonly tierDomains?: Record<string, string[]>
/** Ordered backend ids (config research.backends); overrides searchProvider. */
readonly backends?: string[]
/**
* Full backend chain override (tests and embedders): when set, the order is
* these backends in this order, and availability is still checked per call.
*/
readonly chain?: readonly SourceBackend[]
readonly now?: () => Date
/**
* Explicit human operator (#99): only the CLI sets `operator: "human"`
* when it builds the tools. The per-call `agent` is never trusted for
* this (in the plugin it is the host agent's name, so any agent named
* "human" would pass a name check), and the plugin never sets it.
*/
readonly operator?: "human"
}
/** The research-tool options a loaded config sets. */
export function researchOptions(
config: Pick<EsConfig, "searchProvider" | "research">,
): Pick<ResearchOpsContext, "provider" | "cacheTtlDays" | "searchTimeoutS" | "searxngUrl"> {
): Pick<ResearchOpsContext, "provider" | "backends" | "cacheTtlDays" | "searchTimeoutS" | "searxngUrl"> {
const research = config.research
return {
...(config.searchProvider ? { provider: config.searchProvider } : {}),
...(research?.backends ? { backends: [...research.backends] } : {}),
...(research?.cacheTtlDays !== undefined ? { cacheTtlDays: research.cacheTtlDays } : {}),
...(research?.searchTimeoutS !== undefined ? { searchTimeoutS: research.searchTimeoutS } : {}),
...(research?.searxngUrl ? { searxngUrl: research.searxngUrl } : {}),
@@ -48,8 +75,8 @@ const object = (properties: Record<string, unknown>, required: string[] = []) =>
additionalProperties: false,
})
/** Providers whose snapshots hold the whole page (es_research_fetch and the host's webfetch). */
const FETCHED = new Set(["fetch", "webfetch"])
/** Providers whose snapshots hold the whole page: legacy ids plus every fetch-capable backend. */
const fetchedSnapshot = (providerId: string): boolean => isFetchedSnapshot(providerId)
/**
* The engine cache for a project. Entries are sealed with the engine key
@@ -60,16 +87,28 @@ export function researchCache(root: string, stateDir: string = defaultStateDir()
return new SourceCache(researchSourcesDir(root), stateDir)
}
/** The CLI operator runs research tools as an explicit human operator; seats keep the registry check. */
export function researchTools(context: ResearchOpsContext): EsToolDef[] {
const humanOperator = context.operator === "human"
/** Seats keep the registry check; the CLI's explicit operator bypasses it. Never the per-call agent name. */
const mayResearch = (agent: string | undefined, tool: string): boolean => humanOperator || seatMayUse(agent, tool)
const cache = researchCache(context.root, context.stateDir ?? defaultStateDir())
const env = context.searxngUrl ? { ...(context.env ?? process.env), SEARXNG_URL: context.searxngUrl } : context.env
const provider = (): SearchProvider => {
const selected = selectProvider(context.provider, env)
if (!selected)
throw new Error(
"No search provider is configured: set BRAVE_API_KEY, FIRECRAWL_API_KEY or SEARXNG_URL (or fetch known URLs directly).",
)
return selected
const order = resolveBackendOrder({
...(context.backends ? { backends: context.backends } : {}),
...(context.provider ? { provider: context.provider } : {}),
})
const chain: readonly SourceBackend[] =
context.chain ?? order.map((id) => getBackend(id)).filter((backend) => backend !== undefined)
// One cooldown map per tools instance (one per plugin process): a down
// backend is skipped for a while instead of retried on every call (#106).
const cooldowns = new Map<string, { until: number; kind: BackendErrorKind }>()
const signals = (toolContext: { signal?: AbortSignal }): AbortSignal | undefined => {
const list = [
...(toolContext.signal ? [toolContext.signal] : []),
...(context.searchTimeoutS ? [AbortSignal.timeout(context.searchTimeoutS * 1000)] : []),
]
return list.length ? (list.length === 1 ? list[0]! : AbortSignal.any(list)) : undefined
}
return [
{
@@ -78,26 +117,30 @@ export function researchTools(context: ResearchOpsContext): EsToolDef[] {
"Search the web with the configured provider. Results are cached; fetch a result with es_research_fetch before quoting it.",
input: object({ query: { type: "string" }, limit: { type: "number" } }, ["query"]),
execute: async ({ query, limit }, toolContext) => {
const chosen = provider()
const signals = [
...(toolContext.signal ? [toolContext.signal] : []),
...(context.searchTimeoutS ? [AbortSignal.timeout(context.searchTimeoutS * 1000)] : []),
]
const results = await chosen.search(query, {
limit: limit ?? 8,
...(signals.length ? { signal: signals.length === 1 ? signals[0]! : AbortSignal.any(signals) } : {}),
...(context.fetch ? { fetch: context.fetch } : {}),
...(env ? { env } : {}),
})
if (!mayResearch(toolContext.agent, "es_research_search"))
throw new ToolRefusal("Only the research and scout seats may search the web.")
const signal = signals(toolContext)
const found = await runSearchChain(
chain,
query,
{
limit: limit ?? 8,
...(signal ? { signal } : {}),
...(context.fetch ? { fetch: context.fetch } : {}),
...(env ? { env } : {}),
},
{ cooldowns },
)
const lines: string[] = []
for (const result of results) {
for (const result of found.value) {
const cached = result.content
? await cache.put({
url: result.url,
title: result.title,
text: result.content,
provider: chosen.id,
provider: found.backend,
query,
...(context.tierDomains ? { tierDomains: context.tierDomains } : {}),
})
: undefined
lines.push(
@@ -105,7 +148,7 @@ export function researchTools(context: ResearchOpsContext): EsToolDef[] {
)
}
return lines.length
? `${chosen.id} results for "${query}":\n${lines.join("\n")}`
? `${found.backend} results for "${query}":\n${lines.join("\n")}`
: `No results for "${query}". Record it as [NEGATIVE_KNOWLEDGE: ${query}] if it matters.`
},
},
@@ -122,6 +165,8 @@ export function researchTools(context: ResearchOpsContext): EsToolDef[] {
["url"],
),
execute: async ({ url, offset, refresh }, toolContext) => {
if (!mayResearch(toolContext.agent, "es_research_fetch"))
throw new ToolRefusal("Only the research and scout seats may fetch a page.")
// The webcache gate: a fresh full-page snapshot of the same canonical URL
// is served from the cache. Search results cached under a page's URL are
// snippets, not the page, so only fetched snapshots count.
@@ -130,7 +175,7 @@ export function researchTools(context: ResearchOpsContext): EsToolDef[] {
? undefined
: await cache.byUrl(url).then((hit) =>
hit &&
FETCHED.has(hit.meta.provider) &&
fetchedSnapshot(hit.meta.provider) &&
isFresh(hit.meta.retrieved, url, {
...(context.cacheTtlDays !== undefined ? { ttlDays: context.cacheTtlDays } : {}),
...(context.now ? { now: context.now() } : {}),
@@ -138,19 +183,24 @@ export function researchTools(context: ResearchOpsContext): EsToolDef[] {
? hit
: undefined,
)
const page = fresh
? undefined
: await fetchPage(url, {
...(toolContext.signal ? { signal: toolContext.signal } : {}),
...(context.fetch ? { fetch: context.fetch } : {}),
})
const fetched =
fresh === undefined
? await runFetchChain(chain, url, {
...(toolContext.signal ? { signal: toolContext.signal } : {}),
...(context.fetch ? { fetch: context.fetch } : {}),
...(env ? { env } : {}),
cooldowns,
})
: undefined
const page = fetched?.value
const cached =
fresh ??
(await cache.put({
url: page!.url,
...(page!.title ? { title: page!.title } : {}),
text: page!.text,
provider: "fetch",
provider: fetched!.backend,
...(context.tierDomains ? { tierDomains: context.tierDomains } : {}),
}))
const title = cached.meta.title
const start = Math.max(0, offset ?? 0)
@@ -179,6 +229,8 @@ export function researchTools(context: ResearchOpsContext): EsToolDef[] {
prune: { type: "boolean" },
}),
execute: async ({ path: target, prune }, toolContext) => {
if (!mayResearch(toolContext.agent, "es_research_audit"))
throw new ToolRefusal("Only the research seats and factory may audit a research artifact.")
const file = path.resolve(
context.root,
target ?? path.relative(context.root, factoryLayout(context.root).researchReport),
+227 -14
View File
@@ -1,6 +1,8 @@
// Pluggable web search providers (Brave, Firecrawl, SearXNG) and a plain
// page fetcher. Each provider is available when its credentials/endpoint are
// configured in the environment; DuckDuckGo scraping is deliberately absent.
import { promises as dns } from "node:dns"
import { isIP } from "node:net"
import { htmlToText } from "./html.ts"
export interface SearchResult {
@@ -21,7 +23,26 @@ export interface SearchProvider {
}
async function json(response: Response, provider: string) {
if (!response.ok) throw new Error(`${provider} search failed: HTTP ${response.status}`)
if (!response.ok) {
// Machine-readable details for the backend chain (#106): the HTTP status
// classifies the failure, and a 429 Retry-After sets the cooldown.
const error = new Error(`${provider} search failed: HTTP ${response.status}`) as Error & {
status?: number
retryAfterMs?: number
}
error.status = response.status
if (response.status === 429) {
const retryAfter = response.headers.get("retry-after")
if (retryAfter !== null) {
if (/^\d+$/.test(retryAfter.trim())) error.retryAfterMs = Number(retryAfter.trim()) * 1000
else {
const at = Date.parse(retryAfter)
if (!Number.isNaN(at)) error.retryAfterMs = Math.max(0, at - Date.now())
}
}
}
throw error
}
return response.json() as Promise<any>
}
@@ -103,30 +124,222 @@ export interface FetchedPage {
}
const MAX_FETCH_BYTES = 3_000_000
const MAX_FETCH_REDIRECTS = 5
/** Fetch a page and convert it to text. Only http(s); HTML is converted, text formats are kept. */
export async function fetchPage(
url: string,
options: { signal?: AbortSignal; fetch?: typeof fetch } = {},
): Promise<FetchedPage> {
if (!/^https?:\/\//i.test(url)) throw new Error("Only http(s) URLs can be fetched")
const response = await (options.fetch ?? fetch)(url, {
/** A fetchPage refusal on safety grounds (SSRF/policy): the chain must never route around it (#106). */
const blocked = (message: string) => {
const error = new Error(message) as Error & { code?: string }
error.code = "blocked"
return error
}
/** An IPv4 dotted quad fetchPage never contacts: loopback, RFC1918, link-local, this-network. */
const blockedIPv4 = (quad: string, allowLoopback: boolean): boolean => {
const octets = quad.split(".").map(Number)
if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return false
const [a, b] = octets as [number, number, number, number]
if (allowLoopback && a === 127) return false
return (
a === 0 || // this network
a === 10 || // RFC1918
a === 127 || // loopback
(a === 172 && b >= 16 && b <= 31) || // RFC1918
(a === 192 && b === 168) || // RFC1918
(a === 169 && b === 254) // link-local, including cloud metadata endpoints
)
}
/** An IPv6 literal (brackets stripped) fetchPage never contacts. */
const blockedIPv6 = (host: string, allowLoopback: boolean): boolean => {
const name = host.toLowerCase()
if (name === "::1") return !allowLoopback
if (name === "::") return true
// An IPv4-mapped tail (::ffff:169.254.169.254, hex or dotted) is judged as IPv4.
const tail = /(?:^|:)(\d+\.\d+\.\d+\.\d+)$/.exec(name)?.[1]
if (tail) return blockedIPv4(tail, allowLoopback)
if (name.startsWith("::ffff:")) {
const words = name.slice("::ffff:".length).split(":")
if (words.length === 2 && words.every((word) => /^[0-9a-f]{1,4}$/.test(word))) {
const pair = words.map((word) => Number.parseInt(word, 16)) as [number, number]
return blockedIPv4(`${pair[0] >> 8}.${pair[0] & 0xff}.${pair[1] >> 8}.${pair[1] & 0xff}`, allowLoopback)
}
return false
}
const first = name.split(":", 1)[0]
return /^fe[89ab]/.test(first ?? "") || /^f[cd]/.test(first ?? "") // link-local, unique-local
}
/**
* Resolve a fetch-target hostname to its addresses (injectable so tests stay
* hermetic; production resolves for real). The default asks the system
* resolver for every address, verbatim.
*/
export type HostResolver = (host: string) => Promise<readonly string[]>
const defaultResolveHost: HostResolver = async (host) =>
(await dns.lookup(host, { all: true, verbatim: true })).map((entry) => entry.address)
/**
* Whether fetchPage must refuse this host without fetching: literal
* non-routable IPs (loopback unless the fixture allowance is on) and
* localhost. The hostname comes from WHATWG URL parsing (the same parser
* fetch uses), so decimal/hex/octal IPv4 tricks arrive already normalised
* to a dotted quad; userinfo never reaches the host.
*/
const blockedHost = (host: string, allowLoopback: boolean): boolean => {
const name = host.toLowerCase().replace(/\.+$/, "")
if (name === "localhost" || name.endsWith(".localhost")) return !allowLoopback
const bare = name.startsWith("[") && name.endsWith("]") ? name.slice(1, -1) : name
// The suffix after the last colon is the only place a dotted quad can
// hide (plain "1.2.3.4", "::1.2.3.4", "fe80::1.2.3.4"). A linear tail
// test replaces the old (?:^|:|^.*:) alternation (Sonar S8786).
const tail = bare.includes(":") ? bare.slice(bare.lastIndexOf(":") + 1) : bare
const quad = /^\d+\.\d+\.\d+\.\d+$/.test(tail) ? tail : undefined
if (quad) return blockedIPv4(quad, allowLoopback)
if (bare.includes(":")) return blockedIPv6(bare, allowLoopback)
return false
}
/**
* Loopback is refused by default. ES_RESEARCH_ALLOW_LOOPBACK=1 re-admits
* 127/8, ::1 and localhost for loopback-only fixture servers in tests —
* the harness's deterministic pages bind 127.0.0.1 and nothing leaves the
* machine. Link-local (169.254/16), RFC1918 and other non-routables stay
* refused either way. Never set this in production.
*/
const loopbackAllowed = (env: NodeJS.ProcessEnv | undefined): boolean =>
/^(1|true|yes)$/i.test(env?.ES_RESEARCH_ALLOW_LOOPBACK ?? "")
/** Parse an http(s) fetch target and refuse non-routable hosts before any fetch. */
const assertRoutable = async (raw: string, allowLoopback: boolean, resolveHost: HostResolver): Promise<URL> => {
let parsed: URL
try {
parsed = new URL(raw)
} catch {
throw blocked("Only http(s) URLs can be fetched")
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw blocked("Only http(s) URLs can be fetched")
if (blockedHost(parsed.hostname, allowLoopback))
throw blocked(`Refusing to fetch ${parsed.hostname}: it resolves to a non-routable address (SSRF guard)`)
// A DNS name is only as routable as what it resolves to: a name like
// 169.254.169.254.nip.io or localtest.me sails past the literal-IP check
// above, so resolve every hop (including each redirect) and refuse when
// ANY address is blocked, honouring the loopback allowance. Lookup
// failures refuse too: fail closed, never fetch on a guess.
//
// Residual window (DNS rebinding): the address checked here is the one the
// resolver returned at check time, not necessarily the one fetch connects
// to — a hostile name can answer differently per query or expire fast. A
// pinning outbound proxy would close it; this guard narrows it to names
// whose every current answer is routable.
if (isIP(parsed.hostname) === 0) {
let addresses: readonly string[]
try {
addresses = await resolveHost(parsed.hostname)
} catch {
throw blocked(`Refusing to fetch ${parsed.hostname}: DNS lookup failed (SSRF guard, fail closed)`)
}
if (addresses.length === 0)
throw blocked(`Refusing to fetch ${parsed.hostname}: it resolves to no address (SSRF guard, fail closed)`)
for (const address of addresses)
if (isIP(address) === 0 || blockedHost(address, allowLoopback))
throw blocked(`Refusing to fetch ${parsed.hostname}: it resolves to a non-routable address (SSRF guard)`)
}
return parsed
}
/** One manual-redirect fetch hop with the research user-agent. */
async function fetchOnce(current: string, doFetch: typeof fetch, signal: AbortSignal): Promise<Response> {
return doFetch(current, {
headers: {
"User-Agent": "epistemic-swarm/1.0 (+research cache)",
Accept: "text/html,text/plain,text/markdown,application/json;q=0.9,*/*;q=0.5",
},
redirect: "follow",
signal: options.signal ?? AbortSignal.timeout(30_000),
redirect: "manual",
signal,
})
if (!response.ok) throw new Error(`Fetching ${url} failed: HTTP ${response.status}`)
}
/** Resolve one redirect target, re-checking routability (DNS included); undefined stops the chain. */
async function followRedirect(
location: string | null | undefined,
current: string,
url: string,
hop: number,
allowLoopback: boolean,
resolveHost: HostResolver,
): Promise<string | undefined> {
if (!location) return undefined
if (hop >= MAX_FETCH_REDIRECTS) throw new Error(`Fetching ${url} failed: too many redirects`)
let next: URL
try {
next = new URL(location, current)
} catch {
return undefined
}
return (await assertRoutable(next.toString(), allowLoopback, resolveHost)).toString()
}
/** Decode a fetched response body to text, refusing binary content. */
async function readBody(response: Response): Promise<{ text: string; contentType: string; title?: string }> {
const contentType = response.headers.get("content-type") ?? "text/plain"
if (/pdf|octet-stream|image\/|video\/|audio\//.test(contentType))
throw new Error(`Cannot cache ${contentType} content as text`)
throw blocked(`Cannot cache ${contentType} content as text`)
const buffer = await response.arrayBuffer()
const raw = new TextDecoder().decode(buffer.byteLength > MAX_FETCH_BYTES ? buffer.slice(0, MAX_FETCH_BYTES) : buffer)
if (/html|xml/.test(contentType)) {
const page = htmlToText(raw)
return { url: response.url || url, ...(page.title ? { title: page.title } : {}), text: page.text, contentType }
return { text: page.text, contentType, ...(page.title ? { title: page.title } : {}) }
}
return { url: response.url || url, text: raw, contentType }
return { text: raw, contentType }
}
/** Fetch a page and convert it to text. Only http(s); HTML is converted, text formats are kept. */
export async function fetchPage(
url: string,
options: {
signal?: AbortSignal
fetch?: typeof fetch
env?: NodeJS.ProcessEnv
/** DNS resolver for the SSRF guard (tests inject a fake); default resolves for real. */
resolveHost?: HostResolver
/** Per-hop fetch budget in milliseconds (default 30 s); every hop gets its own. */
timeoutMs?: number
} = {},
): Promise<FetchedPage> {
if (!/^https?:\/\//i.test(url)) throw blocked("Only http(s) URLs can be fetched")
const doFetch = options.fetch ?? fetch
const allowLoopback = loopbackAllowed(options.env ?? process.env)
const resolveHost = options.resolveHost ?? defaultResolveHost
const timeoutMs = options.timeoutMs ?? 30_000
// Redirects are followed one hop at a time so every hop is re-checked
// (literals and DNS): a redirect onto an internal address is refused
// before it is fetched. The first hop keeps the caller's spelling (like
// response.url || url before); redirect targets use the normalised
// absolute form. Each hop gets its own timeout, combined with the caller
// signal, so a slow first hop cannot starve the next hop's budget — while
// a caller abort still aborts. The hop count stays capped at
// MAX_FETCH_REDIRECTS.
let current = url
await assertRoutable(current, allowLoopback, resolveHost)
let response: Response | undefined
for (let hop = 0; ; hop++) {
const hopTimeout = AbortSignal.timeout(timeoutMs)
const signal = options.signal ? AbortSignal.any([options.signal, hopTimeout]) : hopTimeout
response = await fetchOnce(current, doFetch, signal)
const next = await followRedirect(
response.status >= 300 && response.status < 400 ? response.headers.get("location") : undefined,
current,
url,
hop,
allowLoopback,
resolveHost,
)
if (next === undefined) break
current = next
}
if (response === undefined) throw new Error(`Fetching ${url} failed: no response`)
if (!response.ok) throw new Error(`Fetching ${url} failed: HTTP ${response.status}`)
const { text, contentType, title } = await readBody(response)
return { url: response.url || current, ...(title ? { title } : {}), text, contentType }
}
+130 -2
View File
@@ -51,11 +51,16 @@ export interface QuoteCheck {
readonly reason?: string
}
export function verifyQuote(source: string, quote: string): QuoteCheck {
const fragments = normalizeForQuote(quote)
/** Split a prose quote on ellipses; each fragment must stand as evidence. */
function quoteFragments(quote: string): string[] {
return normalizeForQuote(quote)
.split(/\.\.\.|…/)
.map((fragment) => fragment.trim())
.filter(Boolean)
}
export function verifyQuote(source: string, quote: string): QuoteCheck {
const fragments = quoteFragments(quote)
if (fragments.length === 0) return { ok: false, reason: "empty quote" }
if (fragments.length === 1 && fragments[0]!.length < MIN_QUOTE)
return { ok: false, reason: `quote too short to be evidence (need at least ${MIN_QUOTE} characters)` }
@@ -85,3 +90,126 @@ export function verifySpan(source: string, span: string): QuoteCheck {
const at = normalizeForQuote(source).indexOf(needle)
return at < 0 ? { ok: false, reason: `not found verbatim: "${needle.slice(0, 80)}"` } : { ok: true, index: at }
}
export interface NormalizedMap {
/** Exactly what `normalizeForQuote` returns for the same input. */
readonly text: string
/** Original offset of each normalised character (non-decreasing). */
readonly map: readonly number[]
}
interface CharSlot {
ch: string
src: number
}
const typographyOf = (() => {
// Non-global copies: `test` on a /g pattern would carry lastIndex state.
const folds = TYPOGRAPHY.map(([pattern, replacement]) => [new RegExp(pattern.source), replacement] as const)
return (ch: string): string => {
for (const [pattern, replacement] of folds) if (pattern.test(ch)) return replacement
return ch
}
})()
/**
* `normalizeForQuote` with an index map, step for step: per-character NFKC
* (whole-string NFKC differs only for exotic compositions), typography
* folds, markdown-link stripping, emphasis removal, whitespace collapsing
* and trimming. The contract is `normalizeWithMap(x).text ===
* normalizeForQuote(x)` (tested); `locateQuote` additionally self-checks
* every range it derives, so a divergence degrades to "no highlight",
* never to a wrong highlight.
*/
export function normalizeWithMap(text: string): NormalizedMap {
let slots: CharSlot[] = []
for (let i = 0; i < text.length; i++) {
const normalized = text[i]!.normalize("NFKC")
for (const ch of normalized) slots.push({ ch, src: i })
}
slots = slots.map((slot) => ({ ch: typographyOf(slot.ch), src: slot.src }))
// stripMarkdownLinks, carrying offsets.
const kept: CharSlot[] = []
{
let i = 0
const chars = slots.map((slot) => slot.ch).join("")
while (i < slots.length) {
const open = chars.indexOf("[", i)
if (open < 0) break
const close = chars.indexOf("]", open + 1)
const end = close >= 0 && chars[close + 1] === "(" ? chars.indexOf(")", close + 2) : -1
if (end < 0) {
kept.push(slots[open]!)
i = open + 1
continue
}
for (let k = i; k < open; k++) kept.push(slots[k]!)
for (let k = open + 1; k < close; k++) kept.push(slots[k]!)
i = end + 1
}
for (let k = i; k < slots.length; k++) kept.push(slots[k]!)
}
slots = kept.filter((slot) => slot.ch !== "*" && slot.ch !== "_" && slot.ch !== "`")
// Whitespace runs collapse to one space, kept at the run's first offset.
const collapsed: CharSlot[] = []
for (const slot of slots) {
if (/\s/.test(slot.ch)) {
if (collapsed.length === 0 || collapsed[collapsed.length - 1]!.ch !== " ")
collapsed.push({ ch: " ", src: slot.src })
} else collapsed.push(slot)
}
while (collapsed.length > 0 && collapsed[0]!.ch === " ") collapsed.shift()
while (collapsed.length > 0 && collapsed[collapsed.length - 1]!.ch === " ") collapsed.pop()
return { text: collapsed.map((slot) => slot.ch).join(""), map: collapsed.map((slot) => slot.src) }
}
export interface QuoteRange {
/** Original-text offsets: `source.slice(start, end)` is the fragment. */
readonly start: number
readonly end: number
}
/**
* Where each ellipsis fragment of `quote` sits in the original `source`.
* `verifyQuote` stays the decision authority: anything it refuses is
* `undefined` here. Normalisation drops characters (link targets,
* emphasis marks), so one fragment can become several ranges — split
* wherever the index map jumps over non-whitespace. Whitespace-only gaps
* (collapsed runs) are absorbed into the range. Every result is
* self-checked (the ranges must normalise back to the fragment modulo
* whitespace); a divergence returns `undefined` instead of a wrong
* highlight.
*/
export function locateQuote(source: string, quote: string): QuoteRange[] | undefined {
if (!verifyQuote(source, quote).ok) return undefined
const { text: haystack, map } = normalizeWithMap(source)
const ranges: QuoteRange[] = []
let from = 0
for (const fragment of quoteFragments(quote)) {
const at = haystack.indexOf(fragment, from)
if (at < 0) return undefined
const before = ranges.length
let start = map[at]!
let end = start + 1
for (let j = at + 1; j < at + fragment.length; j++) {
const offset = map[j]!
if (offset <= end) {
end = Math.max(end, offset + 1)
} else if (/^\s*$/.test(source.slice(end, offset))) {
end = offset + 1
} else {
ranges.push({ start, end })
start = offset
end = offset + 1
}
}
ranges.push({ start, end })
const back = ranges
.slice(before)
.map((range) => normalizeForQuote(source.slice(range.start, range.end)))
.join("")
if (back.replace(/\s+/g, "") !== fragment.replace(/\s+/g, "")) return undefined
from = at + fragment.length
}
return ranges
}
+60
View File
@@ -0,0 +1,60 @@
// Deterministic source-tier classification (#113): which shelf a cached
// source sits on (preprint, peer-reviewed, docs, press), so domain packs can
// weight verified claims by source quality. Rules are host-based and
// auditable; unknown hosts stay tierless and fall back to `__default__`.
// Packs may add a `tierDomains` override, which wins over the built-ins.
import { DOC_DOMAINS } from "./url.ts"
const PREPRINT = ["arxiv.org", "biorxiv.org", "medrxiv.org", "ssrn.com"] as const
const PEER_REVIEWED = [
"doi.org",
"pubmed.ncbi.nlm.nih.gov",
"ncbi.nlm.nih.gov",
"nature.com",
"science.org",
"sciencedirect.com",
"springer.com",
"wiley.com",
"nejm.org",
"thelancet.com",
"plos.org",
"frontiersin.org",
"bmj.com",
"jamanetwork.com",
] as const
const hostOf = (url: string): string | undefined => {
try {
const host = new URL(url.trim()).hostname.toLowerCase()
return host ? host.replace(/\.$/, "") : undefined
} catch {
return undefined
}
}
const matches = (host: string, domain: string): boolean => host === domain || host.endsWith(`.${domain}`)
/**
* Classify a source URL into a tier, or undefined when no rule fires.
* `overrides` (a pack's `tierDomains`) is checked first and wins.
*/
export function classifySourceTier(
url: string,
overrides?: Readonly<Record<string, readonly string[]>>,
): string | undefined {
const host = hostOf(url)
if (!host) return undefined
if (overrides)
for (const [tier, domains] of Object.entries(overrides))
if (domains.some((domain) => matches(host, domain.toLowerCase()))) return tier
if (PREPRINT.some((domain) => matches(host, domain))) return "PREPRINT"
if (PEER_REVIEWED.some((domain) => matches(host, domain))) return "PEER_REVIEWED"
if (
(DOC_DOMAINS as readonly string[]).some((domain) => matches(host, domain)) ||
host.startsWith("docs.") ||
host.startsWith("developer.")
)
return "TECHNICAL_DOCUMENTATION"
if (host.endsWith(".gov")) return "PRIMARY_PRESS"
return undefined
}

Some files were not shown because too many files have changed in this diff Show More