f0rge — build plan
docs/PLAN.md · wiki index
f0rge — build plan
Status: to be built. This is the how/when. OUTLINE.md is the what/why. Read OUTLINE first, then this. Engineering language: Rust, one binary, near-zero deps.
0. Build principles — read before every chunk
- Bitter lesson compliant. The forge stays dumb; agent intelligence does the work. No locks, no claim protocols, no auto-resolvers, no CI frameworks, no merge engines. Orchestration lives in the orchestrator (herdr); collisions are resolved by the agents that made them.
- gh-literal. Same command tree, same flags, same
--jsonfield names the models were trained on. The command tree mirrors the folder tree:.forge/issues/↔f0rge issue,.forge/prs/↔f0rge pr,.forge/ci/↔f0rge ci run, … - Files are the API. Markdown is truth; the CLI is the enforced view. The CLI never holds state — it reads and writes the same bytes an agent would by hand.
- Token-minimal everywhere surface touches an agent. Help, lists, errors, the skill — budgets are defined below and enforced by tests. Elegant before complete. If a surface can be 5 lines instead of 15, 5.
- Nothing is deleted. Close/archive only:
state: closed|archivedin frontmatter, and archive moves the folder to.forge/archive/. git is the undo log; history is the product. - CI is just scripts. Steps are files, run in filename order, first
nonzero exit is the verdict. Local shell, docker, a remote box — the
script decides, or
ci.envnudges. No abstraction above the script. - Merges are handled by agents.
f0rge mergeapplies a patch, runs CI, reports what conflicted, and stops. Resolution is the agent's job. The merge engine isgit; the intelligence is the agent. - One proven way, put where agents want it. Agents already know gh. We don't invent a new UX; we put the gh layout inside the repo where the work happens, and the skill is a pointer, not a manual.
- Untrusted is the default for anything that arrives in a PR — patches,
ci/steps/,.forge/edits from someone else's agent. Trust is earned per payload, per posture, never granted by the tool (see §5).
1. Workspace (where the code lives)
f0rge/
├── Cargo.toml # workspace
├── crates/
│ ├── core/ # schema, frontmatter, derived indexes, json models
│ └── cli/ # binary: arg parse, dispatch, output, git shell-outs
├── spec/ # schema v1 (single source of truth) + golden fixtures
├── skill/ # the .pi skill text (final, token-counted)
└── tests/ # end-to-end CLI tests against temp repos
Dependencies: serde, serde_json, yaml_serde, uuid — all
exact-pinned, declared once in [workspace.dependencies] (see §5
supply chain). Everything else is std.
git is invoked as a subprocess (git format-patch, git apply,
git diff, git merge-tree) — never reimplemented. Arg parsing is
hand-rolled, table-driven, ~200 lines — because clap's help output is
verbose on purpose and we own every token of surface here. Timestamps
are hand-rolled RFC3339-UTC formatting from SystemTime (~40 lines in
core) — no time crate, one less dependency to trust.
Dev tooling (locked in regardless): cargo audit, cargo deny,
cargo vet, cargo clippy — all run as f0rge's own dogfood CI steps.
Both crates #![forbid(unsafe_code)].
2. Milestones and their dependency graph
M0 schema v1 frozen (repo-root spec/) — blocks everything
M1 core storage + derived indexes — blocks all entity commands
M2 CLI skeleton + token budgets — blocks polish; parallel w/ M3
M3 entity commands: issues, prs, labels, milestones, tasks
M4 ci run — needs M1; parallel w/ M3
M5 wiki + releases — needs M1; parallel w/ M3
M6 merge / import — needs M3 (pr) + M4
M7 skill + herdr/gl0w integration + audit — needs M3–M6
M8 hardening: concurrency, perf, migration — needs all
Chunks within a milestone are swarmable: each chunk below states its inputs, outputs, interfaces, and definition of done. A subagent claims a chunk, does it against the workspace, runs its acceptance tests, moves on.
M0 — Schema v1 (frozen first)
Input: OUTLINE.md §5. Output: spec/schema.md + golden fixtures for every
entity.
Decisions, locked now:
- Identity: uuid4, lowercase, no dashes stripped. Identity never changes.
- Timestamps: RFC3339, UTC, second precision (
2025-09-08T02:00:00Z). - Frontmatter: YAML subset (
serde_yaml), always between---fences. - Numbers: derived, never stored.
rankof uuid in the total order(created, uuid)computed at list time. Same inputs → same numbers in any copy. - Archive: entity gets
state: archivedand its folder moves to.forge/archive/<type>/<uuid>/. Nothing is deleted; indices skip archived by default. - CI verdict:
pass | fail | running | cancelledstored only in thepr.mdci:field and the run summary. Logs live in gitignored runs. ci/runs/andreleases/out/are the only gitignored paths.
Entity schemas (all in spec/schema.md with one golden example file each):
issue, issue-comment, pr, pr-review, task, label, milestone,
release, merge-record, ci-env, ci-step.
Definition of done: every golden fixture parses and round-trips; an invalid frontmatter yields an error naming the file and the offending field. No byte of this schema changes after M0 without bumping the schema version in the contract.
M1 — Core storage + derived indexes (crates/core)
Input: M0. Output: f0rge-core crate, fully tested.
- Path resolution: a
Forgestruct rooted at.forge/, resolves every entity path from uuid. Paths are the only place folder layout lives. - Atomic writes: write
tmp, fsync, rename. No partial files, ever. - Frontmatter parse/serialize: structs per entity,
serde, strict field validation (unknown field = warning; missing required = error). - Load/save:
load::<Issue>(uuid),save(&entity); comments/reviews are separate files, loaded lazily. - Derived indexes (pure functions, deterministic):
- issue numbers from
(created, uuid)order - kanban columns from task
status(todo | doing | blocked | done) - wiki index from file tree + first H1
--jsonmodels with gh field names (number,title,state,labels, assignees,milestone,body,createdAt,databaseId,status,conclusion)
- issue numbers from
- Tests: unit + golden for every index; property test: index stability under insertion order permutation.
Definition of done: cargo test green; a 10k-issue directory list
parses under 100 ms debug build; SEC-01 + SEC-02 covered by fixtures
(size-bomb, alias-bomb, symlink-escape, oversize file → clean errors,
never a panic).
M2 — CLI skeleton + token budgets (crates/cli)
Input: M0, M1. Output: the binary's spine: dispatch, flags, output, errors.
- Command tree (mirrors the folder tree; all gh-literal):
f0rge
├── issue create list view comment close reopen assign label edit
├── pr create diff review comment merge close list
├── task create list move done
├── label list create edit
├── milestone list create edit
├── ci run list view log rerun cancel
├── wiki get put list
├── release create list view
├── merge <folder|zip|branch>
├── init [dir]
└── --skill # the contract, short
- Flags:
--title --body --label --assignee --milestone --base --head --draft --json --jq. Unknown flag → exit 2 with a one-line message. - Exit codes: 0 ok, 1 runtime error (message on stderr, one line), 2 usage.
- Output:
--jsonis compact (no spaces after:/,), field order is fixed and documented. Human mode exists but is a thin, aligned rendering of the same data. Never ANSI color on non-tty; never colored for agents. --jqshells out tojqwhen present; absent → clear one-line note.
Token budgets (hard, enforced by tests that byte-count output):
| surface | budget |
|---|---|
f0rge (root help) | ≤ 12 lines, ≤ 300 bytes |
any subcommand -h | ≤ 8 lines, ≤ 250 bytes |
f0rge --skill | ≤ 30 lines, ≤ 1000 bytes |
list --json | 1 compact line per entity, no header/boilerplate |
| error | 1 line + exit code |
| empty result | [] or {} — no prose |
Definition of done: every command in the tree responds with correct exit
codes; budget tests pass; a fresh agent can discover the entire surface
from f0rge alone; SEC-04 + SEC-10 enforced by tests (argv-injection
fixture, one-line-error budget).
M3 — Entity commands
Input: M1, M2. Four parallel chunks (3a–3d), each implements its commands with golden JSON output tests and e2e tests against a temp repo.
3a issues
create (mkdir uuid dir, write issue.md, print json), list (derived
numbers, filters: --label --assignee --state --milestone --search),
view (body + comments), comment (append-only new file), close /
reopen (edit only the owning file's state), assign (append/remove
assignee — remove only by full rewrite of owning file), label (same),
edit (body/title rewrite).
3b prs
create from branch: git format-patch base..head → patch.diff, write
pr.md (--base --head --draft --issue). diff → head of patch.
review/comment → append review files. merge (see M6, minimal here:
apply patch via git apply, on conflict write merge-record + exit 1 with
the offending files listed, no resolution). close → state closed.
list → derived number, state, ci verdict, title.
3c labels + milestones
label list|create|edit against labels.md (definition list: name,
color, description). Removal of a definition is allowed; historical uses
live in entity frontmatter and never disappear. milestone list|create| edit against milestones.md (name, due, description).
3d tasks/kanban
task create (uuid card, status: todo), list (columns derived from
status — a plain grouped render), move <id> <col> (rewrite owning file
status), done (= move done + optional closed: timestamp). No board
file, no stored column order.
Definition of done (all of M3): a full swarm loop works by hand on a temp
repo — agent creates issue → claims it → does work → files PR → you merge.
All JSON outputs match the gh field names documented in spec/schema.md.
M4 — CI (f0rge ci)
Input: M1, M2. Output: runner + run records. No framework allowed.
ci run [--docker]: iteratesteps/in filename order; each is an executable; first nonzero exit stops the run (fail-fast);--continue-on- errorruns all. Env:ci.env(non-secret) + shell defaults.--dockerwraps the run in the container fromci.env(default: plain local). That's the entire abstraction. A step that needs something specific declares it in its own shebang/script or inci.env— nothing else prescribes.- Posture is explicit, never implicit (SEC-07):
ci.envmay setrunner: local|docker;--dockeroverrides to docker; runs inherit only the caller's environment — the forge never injects secrets, and nothing in.forge/is ever granted special trust. Steps are code you chose to run, under the posture you chose. - Record:
.forge/ci/runs/<ts>/summary.md(per-step: name, duration, exit; final verdict = exit code) +steps/<name>.log. Runs dir is gitignored; verdicts flow intopr.md'sci:field viaci run <pr>. ci list(most recent runs, verdict),view <run>(summary),log <run> [step],rerun(new run dir, same steps),cancel(kill current run; recordcancelled).
Definition of done: a shell step that exits 1 fails the run; a docker
step runs inside docker when --docker; run records match schema; a
green run's verdict lands in the associated pr.md; posture honored
per SEC-07.
M5 — Wiki + releases
Input: M1, M2.
wiki get <path>,put <path> -(stdin),list(index derived from tree + first H1; never stored). Wiki editing is a skill convention, not a command: pages are.forge/wiki/files — edit them in place with editor tools (or wr1te), or viawiki put <path> -. Paths are flat-ish (docs/,research/,experiments/).release create <ver> --notes <file|->: writereleases/<ver>.md, then runreleases/build/scripts at the tag: checkout, build per platform intoout/(gitignored).release list|view.
Definition of done: an agent can persist research via f0rge wiki put,
read it back across compactions, and ship v0.1.0 with release create.
M6 — Merge / import
Input: M3b, M4. The flagship-understatement: a utility, not a ceremony.
f0rge merge <folder|zip|branch>:- import the payload into a fresh disposable worktree, never the live checkout (SEC-05)
git apply --check patch.diffagainst base — git's own patch path validation stays on; nothing is applied without passing the check- against
merges/history, compare patch hashes — if it looks already landed, report and stop (SEC-08) - on clean apply: apply, run CI, on green update
pr.mdstate → merged, appendmerges/<ts>.mdrecord{pr, applied, verdict, resolved: []} - on conflict: stop. Write merge-record naming the conflicting files,
exit 1. The agent reconciles and re-runs. No auto-resolution, no
merge driver, no rebase magic —
gitreports, the agent decides.
- Merge commits from
f0rgedisable hooks and set no merge drivers (SEC-06);git applyitself runs no hooks.
Definition of done: zip-in-a-repo → merged → record written, and a conflicting payload fails loudly with a short, parseable conflict list; SEC-05/06/08 fixtures green (traversal patch, hook-injecting patch, replayed patch all fail cleanly).
M7 — Skill + integration + audit
Input: M3–M6. Output: the shipped experience.
skill/skill.md: the entire .pi skill, final text, ≤ 30 lines. It says: f0rge is gh, files instead of api.github.com; the folder tree is the truth; runf0rgeto see the surface; never parse prose, use--json.--skilloutput: the same contract in ≤ 30 lines / ≤ 900 bytes — schema version, identity rules, file rules, ci verdict rule. Beautiful: short lines, aligned, no headings you can't act on.- herdr/gl0w smoke test: one real loop end-to-end (issue → work → PR → CI → merge) run entirely by a fresh agent, measured. Token audit: count every token the agent consumed discovering and using f0rge; budget it, then cut it. This metric is the acceptance test for §0.4.
- Fix whatever the audit surfaces (help text, skill text, output noise).
Definition of done: audit passes; the skill fits in the budget; the loop needs zero human help.
M8 — Hardening
Input: all. No new features; make it boring.
- Concurrency: atomic writes everywhere (M1 already); document the read-modify-write race on the owning file and stop there — the reconciling agent is the fix (bitter lesson; no locks).
- Perf: 10k issues
list --json< 300 ms release; wiki index of 500 files < 100 ms. - Migration: schema version lives in the contract (
--skill); CLI understands schema N and N−1 with a one-hop note inmerges/when it converts a store. .gitignoredefaults written byf0rge init:.forge/ci/runs/,.forge/releases/out/,*.tmp.- Supply-chain gates (SEC-09):
cargo audit+cargo deny+cargo vetgreen at known-good pins;cargo build --locked; re-verify the §5 pin table against crates.io and the RustSec DB before blessing. - Release hashes:
f0rge release createrecords sha256 of every built artifact inreleases/<ver>.md. - Sync the OUTLINE open-questions section to what got decided.
Definition of done: a repo with 10k entities and 500 wiki files is pleasant to use; schema N−1 stores import cleanly; zero outstanding perf or concurrency bugs in the tracker; supply-chain gates green; artifact hashes documented.
3. Swarm protocol (how chunks get claimed)
- A chunk lists Inputs / Outputs / Interfaces / Definition of done.
Claim one, build it, run its acceptance tests, record the result in
build-log.md(co-located in.forge/wiki/docs/) (one line per chunk: chunk, sha, verdict). - Monorepo, single trunk: work happens in worktrees; merges onto main are the same ritual as the product itself — this repo eats its own dogfood from M3 onward.
- Order matters at milestone boundaries (M0 → M1 → …), not within a milestone (3a–3d, M4, M5 are parallel).
- When a chunk's acceptance tests are green, the milestone is done; anything that smells like a design change goes back to OUTLINE.md first.
4. Explicit non-goals (same as OUTLINE §4)
Hosting commands, auth, api tokens, secrets, gists, codespaces, browse, webhooks, server of any kind, a merge engine, a CI framework, a lock service, and any UI aimed at humans that isn't also the JSON view.
5. Security model, supply chain & trust
Trust model (who trusts what)
You trust your own repo. Untrusted input arrives in exactly two shapes, and both are handled by the same ritual — never by trusting the tool:
- PR payloads —
patch.diff,ci/steps/,.forge/edits from an agent you did not write. Ritual: apply in a disposable worktree, run their steps under the posture you chose (local or docker), have an agent review, merge only what you accept. - Foreign repos — a
.forge/zip imported viaf0rge merge. Same as (1), plus: their entities are untrusted YAML and their wiki is untrusted markdown until reviewed.
Secrets never enter the repo or payloads (OUTLINE §7). The forge has no server, no network listeners, no auth surface; its attack surface is the filesystem and the subprocesses it runs.
Threat list and mitigations (SEC ids referenced from milestones)
| id | threat | mitigation | lives in |
|---|---|---|---|
| SEC-01 | hostile frontmatter (YAML bombs, alias expansion, giant files) | size cap 256 KiB + depth cap before parse; strict schema (unknown field = warning, missing required = error); yaml_serde (maintained), never serde_yaml/serde_yml | M1 |
| SEC-02 | path escape via file/entity reads (symlink out of .forge/) | canonicalize + prefix check on every resolved path; reject symlinks that escape; don't follow on archive moves | M1, M2 |
| SEC-03 | partial writes from crashes/kills | atomic write (tmp + fsync + rename) on every save | M1 |
| SEC-04 | shell injection via filenames/args | all subprocesses via argv arrays (Command::new); zero sh -c interpolation of untrusted strings | M2, M6 |
| SEC-05 | hostile patches (path traversal, writes outside repo) | git apply --check first; git's built-in patch path validation stays on; apply only in a fresh disposable worktree | M6 |
| SEC-06 | payload runs hooks/code during merge | git apply runs no hooks; merge commits run with hooks disabled; no merge drivers | M6 |
| SEC-07 | untrusted CI steps on your machine | posture explicit: ci.env (`runner: local | docker) or --docker`; runs inherit only caller env — forge injects no secrets |
| SEC-08 | already-landed payload applied twice | patch-hash check against merges/ history before apply | M6 |
| SEC-09 | supply chain | pins, lockfile, audit/deny/vet gates (below) | M8 |
| SEC-10 | information leaks in errors | one-line errors, no paths/snippets beyond the offending file name; --json whitelists fields only | M2 |
Supply chain (crate pinning)
- All deps are exact-pinned (
=) inCargo.toml, declared once in[workspace.dependencies]. No^, no floats, no git deps. Cargo.lockis committed and blessed; builds usecargo build --locked.- At M1: pin table verified against crates.io + RustSec with
cargo auditandcargo deny(licenses, duplicate versions). Re-verified on every CI run as00_audit.shin f0rge's own.forge/ci/steps/— dogfood. cargo vetaudit trail for transitive deps; a crate without a vet entry is flagged, never silently trusted.- Both crates
#![forbid(unsafe_code)]; CI buildsRUSTFLAGS="-D warnings".
Exact pins as of plan time (re-verify at M1 before blessing):
| crate | pin | notes |
|---|---|---|
| serde | =1.0.229 | derive feature |
| serde_json | =1.0.151 | output layer — --json everywhere |
| yaml_serde | =0.10.7 | maintained fork of serde_yaml by the YAML org; check RustSec at M1, roll to the 1.x line if 0.10.x gains advisories |
| uuid | =1.26.0 | v4 feature only |
Do-not-use list (subagents: never "update" onto these): serde_yaml
(deprecated/archived, 0.9.34+deprecated), serde_yml (RUSTSEC-2025-0068,
unsound + unmaintained), serde_yml shims (noyalib/libyml/compat). Anything
else requires pin + audit + vet with the reason written down.
External trust: git and optional jq come from the system; the merge
path uses only the git plumbing named in §1; a missing jq degrades to
a one-line note, never a hand-rolled fallback parser.