Skip to content

BoringStack full-stack build loop (Phase C) + tooling alignment#83

Open
agjs wants to merge 65 commits into
mainfrom
feat/boringstack-build-loop
Open

BoringStack full-stack build loop (Phase C) + tooling alignment#83
agjs wants to merge 65 commits into
mainfrom
feat/boringstack-build-loop

Conversation

@agjs

@agjs agjs commented Jul 12, 2026

Copy link
Copy Markdown
Owner

What

The BoringStack full-stack build loop: for web apps, the harness scaffolds/drives BoringStack (Bun+Elysia API, Vite+React UI, Postgres/Drizzle) one resource at a time — the harness runs BoringStack's own generators + deterministic wiring, the model fills domain logic + tests, evaluated by BoringStack's own validate/check. Supersedes the UI-only web scaffold that couldn't converge.

New: packages/core/src/loop/boringstack/{plan-resources,wire-resource,generate,gate,refine-prompt,build,exec,case}.ts (+ tests, 37 pass), driven by the existing greenfield engine (runGreenfield). Entry: TSFORGE_BORINGSTACK=1 bun packages/core/scripts/headless-build.ts "<goal>" react <clone>.

Built via subagent-driven-development (fresh subagent per task + two-stage review + a final whole-branch review); all review findings fixed.

Proven

  • Gate proven GREEN live on a real BoringStack clone: a full slice (API resource + wiring + db:push + UI feature + generate:api) passes BoringStack's own validate/checkAPI_EXIT=0, UI_EXIT=0, ROOT_EXIT=0.
  • Gate runs the way a developer runs it: bun install + host validate (not a throwaway container — deps live in per-app volumes), using BoringStack's pinned tooling (bun run format, never bunx-latest — a version drift false-failed the gate).
  • Full bun run validate green.

Also in this PR

  • Tooling aligned with BoringStack (prettier 3.9.4, eslint 10.6.0, typescript-eslint 8.62.1) + .github/dependabot.yml (dev-toolchain grouped) — so a version drift never silently breaks a gated build.
  • Removed the self-harness UI-only web corpus (evaluate-web) and retargeted the campaign to the spec corpus (rest of self-harness intact).

Pending (honest)

  • End-to-end model build: a live smoke ran the full harness pipeline (plan→generate→wire) but crashed at generate:api because the polluted test clone's api was unhealthy — a clean end-to-end proof needs a fresh, healthy clone.
  • Per-project isolation (dynamic compose project name / host ports / gate image / DATABASE_URL) — hardcoded-for-one-project today; needed for concurrent multi-project builds.
  • Full removal of the legacy UI-only scaffold subsystem (scaffold tools, web-templates, web-gate, staged-build, --web) — core-woven (~25 files); planned as a follow-up (see docs/superpowers/plans/2026-07-12-boringstack-build-loop.md Task 8).
  • Driver robustness: generate:api api-readiness poll; a resume wrapper for the single-invocation headless path.

Do not merge without review — flagging the pending items above.

agjs added 30 commits July 12, 2026 12:25
…ll-stack build loop against a scaffolded clone; type onEvent as Reporter
…idate, not docker-run) + use pinned bun-run-format not bunx-latest

The single docker-run gate had no toolchain (tsc: not found — deps live in per-app volumes). Run the composed gate on the host after bun install, with DATABASE_URL pointed at the published localhost Postgres. And the generator formats via BoringStack's OWN pinned prettier (bun run format), never bunx-latest, which caused a union-formatting drift that failed the gate.
… typescript-eslint 8.62.1) + add dependabot

BoringStack is the strict reference the harness gates against; keep tsforge's shared toolchain on par so a version drift never silently breaks a build (a bunx-latest prettier mismatch already caused a union-formatting false-fail). Reformatted 11 files under prettier 3.9.4. Added .github/dependabot.yml (weekly; dev-toolchain grouped so lint/format/type deps move as one unit) + github-actions updates.
…e) so generate:api syncs the OpenAPI client — fixes root 'OpenAPI drift' gate failure
…-web)

Removes web-build evaluation system and all web: task references from self-harness:
- Delete evaluate-web.ts and self-harness-web.test.ts
- Remove runOneWeb(), webTimeoutMs option, and web: task dispatch from evaluate.ts
- Remove web-corpus exports (runWebTaskOnce, WEB_SLOW_THRESHOLD, WEB_RUN_TIMEOUT_MS, IWebEvaluateOptions, IWebRunOutcome) from index.ts
- Simplify split.ts: remove web: namespace validation logic (webSlugs parameter)
- Update self-harness.ts: remove BENCHMARK_CATALOG import and webSlugs argument

Keeps the rest of self-harness fully intact: Algorithm-1 loop, overlay, mining, proposer, campaign, all spec/code corpus functionality.

All tests green: bun run typecheck, bunx eslint packages, bun test packages/core/tests/self-harness-*.test.ts (56 pass)
… to the spec corpus (auth/fix-regression proof; boringstack corpus is the future successor)
…the live-smoke 'fetch failed' race after resource hot-reload)
… retry (skip codegen if the resource exists — a feature can now be re-attempted to fix gate failures instead of crashing on 'directory already exists')
…he next attempt's prompt (IFeature.lastError) so the model fixes real errors instead of rebuilding blind; dedupe toCamelCase in refine-prompt
…rompt (canonical app.handle(Request) route-test, ApiErrors not throw, TS strict rules) — targets the Elysia-typing failures that stuck the live smoke
…d the dead UI-only web-build path: scaffoldWeb/buildStaged/web-gate/delegation) — Task 8 chunk C1
…ive REPL (keep boringstack/astro scaffold + normal session) — Task 8 chunk C3
…caffold_web/ui/routes tools

Surgically remove the web-only staged-build machinery and its three scaffold tools:
- Delete staged-build.ts and the two-phase build infrastructure (designBuild/implementBuild/buildStaged/generatePlan)
- Remove scaffold_web/ui/routes tool definitions and dispatch handlers
- Remove SetupWebFn type and setupWeb callback threading
- Update session.ts: remove scaffoldWeb/scaffoldUi config, remove 4 public methods
- Update agent.constants.ts: remove 3 tool names, specs, and definitions
- Update execute-tool.ts: remove 3 handler entries
- Update turn.ts, tool-context.ts, cli/repl.ts: remove setup callbacks
- Clean up 3 test files (staged-build.test.ts, staged-gate.test.ts, scaffold-routes-idempotent.test.ts)
- Remove or update 9 test cases that asserted scaffold-tool behavior

All 4 validation gates pass: typecheck, eslint, e2e:pty, test suite (2206 pass)
…lateral-deleted them; the tools survive as general research tools)
…er build BACKEND

pull_conventions was gated behind flags.webTools(), so boringstack builds
(TSFORGE_BORINGSTACK, not TSFORGE_WEB) never got the conventions tool — a real
convergence bug. toolsFor now takes offerConventions (default false); a build
backend that ships a convention library opts in via ISessionConfig.pullConventions.
headless-build sets it true. Web being on no longer drags the tool in.
…th PULL

The 'HOW TO WRITE THIS RIGHT (boringstack)' guide push fired on ANY gated build
that tripped a mapped rule — so a plain CLI build tripping no-restricted-syntax
got boringstack/web-flavored advice (DOM/query narrowing) labeled boringstack.
Now gated on ILoopState.conventionsEnabled, set from the same ISessionConfig
.pullConventions signal that offers the pull_conventions tool: push + pull
activate together, a plain build gets neither. New test locks in the withheld
case.
…before gate

Two blockers found in a live build (Bookmark resource, stuck 0/3):

1. Test-helper catch-22 — new:resource generates the Drizzle table but nothing
   added it to tests/helpers/db.ts's schema re-export. The no-direct-db-in-tests
   rule forbids importing the table from the schema, but the sanctioned helper
   didn't expose it either, so ANY test referencing the table was trapped (the
   model diagnosed this exactly and had no legal fix). Adds a 4th deterministic
   wiring edit (wireTestHelperFile) — idempotent, existence-guarded.

2. Prettier thrashing — the model burned attempts guessing prettier's exact
   line-break formatting, which is deterministic and 100% auto-fixable. implement
   now runs 'bun run format' (pinned, never bunx-latest) over both apps after the
   model writes and before the gate — a dev gets format-on-save, so should the
   model. Best-effort; the gate stays the source of truth. Neither change relaxes
   the gate.
agjs added 30 commits July 12, 2026 22:00
…line capture

A pristine BoringStack scaffold fails its OWN gate on a fresh clone — not the model's
fault. BoringStack's rename step rewrites identifiers repo-wide, which leaves two
documented follow-ups undone: 'bun run regen' (resync OpenAPI client + ACL + doc
catalogs → else OpenAPI-drift RED) and 'bun run format' (re-prettier lines whose width
the rewrite changed → else format:check RED). The scaffold ran rename+setup but skipped
these, so every build opened with a red baseline warning.

driveBuild now runs regen + format after install, before baseline capture. Verified: a
fresh scaffold that was RED on the full composed gate (api validate && ui validate &&
check) now exits 0 — OpenAPI drift ✓, ACL drift ✓, format ✓. Pairs with the BoringStack
test-fixture fix (c2ab47e, separate repo). Best-effort: a residual defect still falls to
the differential gate rather than aborting.
…gate

Build #6 stuck at the JUDGE stage (gate green, but the service ignored description/
priority and close didn't set status) — yet the expert rescue no-op'd. It only looked
for a file PATH in the failure, and a judge critique is prose with none, so
resolveStuckFile found nothing.

New rescueFileFor(): gate stuck → the file named in the errors (as before); judge stuck
→ fall back to the resource's <camel>.service.ts, where the domain logic the judge
flags lives. So the expert now gets the service file + the judge's critique and can
implement the missing fields / state transition — exactly the case where a stronger
model should step in. Null only when neither resolves. Unit-tested (gate path, judge
fallback, no-file).
Complements BoringStack's parameterized compose host ports: the scaffold now assigns a
free host port to every binding (allocateHostPorts) and writes them to the clone's
compose/.env, so a scaffolded project boots ALONGSIDE the dev stack (or another project)
without colliding on 5432/7330/7331/… The compose project name is already per-project via
rename-project.sh; this closes the ports half. The handoff reports the api/ui/postgres
ports. allocatePort is injectable for deterministic tests; a repo tsforge didn't configure
is unchanged (compose defaults to the upstream ports). Proven: a fresh scaffold writes
free ports (e.g. postgres 52662 / api 52664 / ui 52665) into compose/.env.
… baseline

The differential-gate feedback always appended '; N baseline failure(s) hidden' —
confusing noise when the baseline is green (N=0). Now the suppression note only
appears when there's actually something hidden.
…xing them

Declaring params only to `_`-prefix and ignore them is dead weight when TS lets a
stub/impl omit unused TRAILING params entirely. Removed them from the evaluate/gate/
browser/judge impls and the exec test fakes (async () => … satisfies the type). The
`_`-params that remain elsewhere are LEADING/MIDDLE positionals that can't be omitted
(a later param is used) — the one legitimate use of the convention.
…ale describe

- capabilities: drop the removed 'vite (web)' from the scaffold blurb (wizard only
  offers boringstack/astro).
- wizard (boringstack only): two optional steps — admin email (validated) + admin
  password (masked, min 12). Blank = skip (sign up in-app instead).
- thread the creds answers → plan → applyScaffold, which writes SUPERUSER_EMAIL/
  SUPERUSER_PASSWORD to compose/.env BEFORE boot (co-located with the port write), so
  the migrate task's db:seed creates the admin on first boot. Password is secret:
  on disk, redacted in the summary.
- tests: creds land in compose/.env + password never in the summary; absent when not given.
Design for a product-first, interactive planning phase before the greenfield build
loop: human describes the product (+ mockups via vision), a config-routed planner role
(default deepseek-4-pro, swappable) PROPOSES the plan (domain model + feature slices +
UI intent + verification contract), human approves once, then autonomous per-slice
build carrying the approved plan as shared context. For greenfield BoringStack an
approved plan is a HARD precondition (reuses solo_spec_gate). Reviewer role deferred.
TDD task-by-task plan on feat/boringstack-build-loop: planner capability role (T1),
product-plan type + .specs I/O (T2), planner proposal (T3), approval gate (T4),
refinePrompt context threading (T5), build precondition + build-from-slices (T6),
interactive plan orchestration (T7), REPL/wizard/headless enforcement (T8).
- Replace String(s) screen check with direct typeof validation
- Add 5 new rejection tests: empty mustNotHappen, invalid/non-string screens, empty id/name
- All guards now proven by test (plan-store test count: 21 pass)
Removed 5 duplicate test cases (lines 354–532) where rejection tests
were accidentally copied verbatim. File now contains 16 unique tests
(11 original + 5 new rejection tests) instead of 21.
… validate --plan file

Two critical fixes for Task 8 (interactive planning wiring):

1. **REPL plan review**: The review callback in greenfield BoringStack
   interception was auto-approving plans (spec violation). Now uses
   readline.question() to read human input:
   - "approve"/"a"/"y"/empty → { action: "approve" }
   - "cancel"/"c"/"n" → { action: "cancel" }
   - other text → { action: "revise", note: <text> }
   In editor mode (no readline), cancels with guidance to use --plan.

2. **Headless-build plan validation**: The --plan <file> flag copied
   the file to .specs/next.md without validating. Now:
   - Parses with parsePlan() to catch malformed files
   - Checks status is "approved" (rejects "draft")
   - Exits with clear error if invalid
   - Extracted into installPlanFile() helper to keep
     main() cognitive complexity ≤ 20 per linter.

Both fixes tested: full suite 2236 pass (unchanged), eslint clean,
typecheck green.
- cli.ts: collapse status write to one line (prettier)
- propose-plan.ts: drop redundant ?? null (parsePlanJson already returns null)
- run-planning.ts: remove dead inner revision-budget guard (while already guards it)

Final whole-branch review: no Critical/Important; T1/T4 adjudicated
integration-covered; T3/T7 fixed here. Full validate green.
… parseable plan

Live smoke against the local DeepSeek endpoint exposed a unit-green /
live-broken gap: the loose PLANNER_SYSTEM prompt (prose field hints, {...}
placeholders) let the model invent its own richer keys — screen objects instead
of the flat kind enum, primaryAction/navigationLabel/acceptanceCheckCommand,
relationship objects — which the strict isProductPlan guard then rejected, so
proposePlan returned null every time.

Fix is in the PROMPT, not the guard (the flat screen-kind enum and string
relationship/rule hints are intentional — downstream slicesToFeatures /
refinePrompt / UI-gen consume canonical kinds):
- pin exact key names + the flat screens enum (list|detail|form|dashboard)
- add explicit 'use X not Y' rules for the keys the model drifted on
- embed a complete worked example, now a typed PLANNER_EXAMPLE (satisfies
  IProductPlan) serialized into the prompt — single source of truth
- regression test: PLANNER_EXAMPLE must itself pass isProductPlan / parsePlanJson

Live proof: 5/5 valid IProductPlans across bookmark-manager, expense-tracker,
help-desk, recipe-box, event-rsvp; canonical screen enums throughout. Full
validate green.
… + complete port isolation)

Two harness gaps that forced manual intervention during live proofs:

1. db:push hung on drizzle-kit's interactive data-loss prompt in the non-TTY
   exec. generate.ts now runs 'db:push -- --force' (auto-approve; safe — the
   build DB has no real data and schema iteration must proceed unattended).

2. Dynamic port isolation was half-done: ports were allocated + written to the
   clone's compose .env, but NOTHING threaded them into the URLs the harness
   uses to REACH the stack, so an isolated boot/build hit the upstream defaults
   (7330/7331/5432) and false-failed. Completed it:
   - ports.ts: DEFAULT_HOST_PORTS + parseHostPortsEnv/readHostPorts/hostPortOr/
     remapUrlToHostPorts (pure, 15 tests)
   - boot.ts + run-scaffold.ts: health URLs remap to the project's allocated
     ports (else the health check polls the wrong ports)
   - headless-build.ts: DATABASE_URL + OPENAPI_URL derive from the clone's
     allocated ports so db:push / tests / generate:api reach THIS stack

CORS was NOT a harness bug (compose already sets VITE_API_URL=""); the earlier
manual fix was only needed because host processes bypassed compose. Superuser
seeding already automated via the wizard. Full validate green.
…into faking persistence) + drop STACK wizard step

Live build revealed the model faking persistence: new:resource puts the entity's
Drizzle table in the SHARED app.schema.ts with only stub columns (id/userId/name),
but the harness (a) scoped the model out of that file and (b) the refine prompt
explicitly said 'FREEZE — Do not modify: Database schemas'. So the model could not
add url/notes/favorite columns — it kept them in-memory and toggled favorite in
memory, passing the mocked tests while persisting NOTHING. The harness was gaming
its own gate.

Fix (division of labor unchanged — harness generates + wires, model fills domain):
- scopeFor now includes apps/api/src/clients/postgres/schema/app.schema.ts
  (APP_SCHEMA_FILE) so the model can add its entity's columns.
- refinePrompt instructs adding the real columns (from the plan's fields, correct
  Drizzle types) to ITS OWN table only, and explicitly bans in-memory-only fields;
  FREEZE reworded to forbid only OTHER tables/migrations.
- implement() re-runs db:push --force AFTER the model's edits so the new columns
  persist before the gate (generate only pushes the stub schema, pre-edit).

Also: removed the STACK (dev/prod/smoke) wizard step — every local scaffold is dev;
prod/smoke are deploy/CI concerns reachable only via --stack. Defaults to dev.

31 build/refine tests + 60 scaffold tests pass; full validate green.
…PI readiness

Second live build surfaced the follow-on failure after the persistence fix worked
(model added real varchar/text/boolean columns): it used boolean()/text() WITHOUT
adding them to the drizzle-orm/pg-core import → ReferenceError crashed the API on
boot → container unhealthy → 'api not ready' → cascade (every later attempt died at
generateFeature's readiness poll before the model could fix it).

- refinePrompt: explicit rule to import every column builder used (flagged as the
  #1 cause of a failed build), since a missing import is a boot-time ReferenceError.
- generate.ts: waitForApiReady → apiIsReady (returns bool, never throws).
  generateFeature now SKIPS generate:api when the API isn't serving instead of
  aborting — a persistently-down API is almost always the model's own in-progress
  code, so the build proceeds to the GATE, which surfaces the real, actionable
  compiler error ('Cannot find name boolean'). The loop self-heals: next attempt,
  once the model fixes the import, the API recovers and generate:api runs.

6 generate tests pass (incl. new skip-on-not-ready test); full validate green.
…0-min thrash)

Live build thrashed ~30 min on ONE resource: the write-guard rejected the model's
edits as 'rewrites a large span' and forced 'small, targeted' changes, so the model
flailed — failed edits, create-over-existing, even rm/redirect (all blocked) — trying
to rewrite one test file. This is the documented no-forced-surgical-edits failure.

Reference: pi's edit tool has NO size cap (only rule: oldString matches a UNIQUE
region); its write tool overwrites freely; size is soft guidance, never a rejection.

Match it:
- edit: removed the oversizedEdit size cap + LOOP_LIMITS.maxEditLines. edit now takes
  a replacement of ANY size; applyEdits still enforces unique-match. The model, not
  the harness, chooses edit size.
- steer: dropped the 'no whole-file rewrites' clause — the fix is surgical OR full
  rewrite, whichever works.
- create: KEPT the refuse-to-overwrite-a-parseable-file guard, but it is no longer a
  trap (edit does any-size rewrites now). It stays ONLY to stop a wholesale create
  from wiping the SHARED app.schema.ts (all resources' tables) — unrecoverable.

execute-tool + steer tests updated to assert the new freedom; full validate green.
…reachable)

Live build #4 reached green but the Bookmark page was UNREACHABLE — not in the SPA
router. Cause: boringstack's new:feature deliberately leaves routing manual (it
prints 'Next: register the route in src/app/router/routes.tsx'), so the API side was
auto-wired (wireResource) but the UI side was not, and the model is scoped only to
features/<name>/**. The gate passed because it checks the feature's files compile/
lint/test, not that the page is routed.

Fix — the UI analog of wireResource:
- wireUiRouteFile(src, name): pure injector adding a lazy import + an authenticated
  route object (path /<camel>, same ProtectedRoute/AppShell/Suspense wrapper the
  other feature routes use) to app/router/routes.tsx. Idempotent; throws if the
  createBrowserRouter anchor is missing.
- wireUiFeature(cwd, name): orchestrator, existence-guarded (a router-less variant
  just skips).
- generateFeature now calls it after new:feature, so every generated feature is
  reachable by URL automatically.

Verified against the real clone routes.tsx (adds import+route, parses, idempotent);
9 wire-resource tests + full validate green.
…inks 404'd on :7331)

Found live: the email-verification link a user clicks was http://localhost:7331/
verify-email?token=... — the hardcoded default UI port, not the project's allocated
one. The dev API builds browser-facing links (email verification, billing redirects)
from FRONTEND_URL, which the api-dev service reads via API_DEV_FRONTEND_URL (compose
default http://localhost:7331). Isolation allocates a DYNAMIC UI host port but never
set that var, so every link pointed at the wrong (or another project's) port.

configure.ts now writes API_DEV_FRONTEND_URL=http://localhost:<UI_HOST_PORT> to
compose/.env alongside the allocated ports. Same hardcoded-port class as the earlier
boot-health / DATABASE_URL / OPENAPI_URL isolation fixes.

Test asserts UI_HOST_PORT + API_DEV_FRONTEND_URL are written with the allocated port;
full validate green.
…w keys

Found live: the built Bookmark page rendered 'features.bookmark.title' /
'features.bookmark.empty' verbatim. new:feature emits the page using t('features.
<lower>.title') but adds NO translations, and it slips the gate because the i18n
lint rule only flags UNUSED locale keys (not missing ones) and the page references
the keys via a constant the eslint i18n-keys plugin can't statically trace.

wireI18nKeys (called from wireUiFeature) now seeds features.<lower>.{title,empty}
into every locale common.json with deterministic default copy (entity name + a
generic empty state); the model/user refines wording later. Idempotent — never
overwrites existing (human-refined) copy. Pure addFeatureI18nKeys is unit-tested.

Third 'gate-green but visibly broken' gap this feature surfaced (after unreachable
route + wrong email port), all now closed. Full validate green.
…erified' unless it's usable

Three bugs this session were gate-green but broken when a user opened the app
(unreachable route, raw i18n keys, unmounted API) — the strict gate proves code
COMPILES, not that a user can reach + use the feature. Close that class with a
cheap STATIC check (no browser, no booted stack) run inside the build's evaluate:

- reachability.ts: pure checkFeatureReachable(name, {uiRoutes, apiRoutes,
  localeJsons}) → the feature's page must be routed in the SPA router, its resource
  registered in the API route table, and its i18n keys present in every locale.
  Missing SOURCE files (null) are skipped (variant-tolerant); only a present-but-
  unwired file is a defect. verifyFeatureReachable reads them from the clone.
- build.ts: the gate now passes only if the build gate is green AND the feature is
  reachable — else it fails with the specific wiring problems as feedback.

Runs in milliseconds every build; would have caught all three live bugs at gate
time. (Runtime e2e — real render/CRUD via a booted stack — is the heavier opt-in
tier, deliberately not on by default per the resource constraint.) 10 reachability
tests + full validate green.
…as trapped, same as schema)

A fresh end-to-end build got STUCK with 8x i18n-keys/static-translation-key-exists
failures it couldn't fix. Root cause = the same trap class as the Drizzle schema:
the gate forbids literal UI strings, so a real feature UI references i18n keys
(list headers, form labels) — but the locale files live OUTSIDE the model's scope
(features/<camel>/** only), so it physically can't add the keys it uses → stuck.

Fix mirrors the schema fix:
- build.ts scopeFor now includes LOCALE_GLOB (apps/ui/src/lib/i18n/locales/**) so
  the model can add its keys. (create-overwrite guard still protects the shared
  files from a wholesale wipe; edit is unrestricted.)
- refinePrompt instructs: every UI string is t("features.<camel>.<key>"), each key
  must exist under features.<camel> in EVERY locale (parity), add ONLY your keys.

The other stuck-run errors (no-unnecessary-condition, no-unsafe-argument, require-
await) are model-quality strict-TS churn within scope — not a trap; separate lever
(first-shot hints / attempt budget). Build reliability is now stochastic: 1 green
(bshands4) + 1 stuck (bshands5) at the strict-gate edge. 32 build/refine tests +
full validate green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant