Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions packages/core/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<!-- Generated by `bun run arch:build` from packages/core/src. Do not edit by hand. -->

Derived from `packages/core/src`: **30 subsystems**, **586 files**, **110895 lines**, **137 cross-subsystem edges**.
Derived from `packages/core/src`: **30 subsystems**, **596 files**, **112455 lines**, **138 cross-subsystem edges**.


This page is the exhaustive record: every subsystem, every cross-subsystem edge, and
Expand All @@ -13,26 +13,26 @@ inventory, see the hand-drawn map on [Internals](/internals/).

| Subsystem | Purpose | Tier | Files | Lines | Fan-in | Fan-out |
| --- | --- | --- | --- | --- | --- | --- |
| `loop` | The drive-to-green engine: turns, tools, gate settling, steering, adapters | core | 121 | 35388 | 8 | 22 |
| `rule-packs` | The ESLint rule packs the gate enforces, grouped by stack | core | 164 | 19123 | 3 | 3 |
| `render` | Terminal UI — status bar, menus, wizards, markdown, diffs, spinners | core | 40 | 9651 | 7 | 5 |
| `cli` | Argument parsing, the interactive REPL, and per-mode wiring | core | 21 | 7317 | 2 | 18 |
| `loop` | The drive-to-green engine: turns, tools, gate settling, steering, adapters | core | 128 | 36396 | 8 | 22 |
| `rule-packs` | The ESLint rule packs the gate enforces, grouped by stack | core | 165 | 19317 | 3 | 3 |
| `render` | Terminal UI — status bar, menus, wizards, markdown, diffs, spinners | core | 41 | 9726 | 7 | 5 |
| `cli` | Argument parsing, the interactive REPL, and per-mode wiring | core | 21 | 7332 | 2 | 18 |
| `meta-rules` | Gate rules that need no AST — config shape, CI wiring, supply chain | core | 42 | 3424 | 1 | 2 |
| `self-harness` | Lets the harness propose, trial, and keep edits to its own prompts and rules | optional | 15 | 3367 | 2 | 9 |
| `agent` | Tool schemas, the model-as-agent wrapper, and the malformed-tool-call repair ladder | core | 10 | 3102 | 8 | 9 |
| `inference` | OpenAI-compatible provider: streaming, tool calls, reasoning, token usage | core | 12 | 3048 | 10 | 4 |
| `scaffold` | Stands up a new project from an archetype and configures its gate | optional | 15 | 2526 | 3 | 2 |
| `scaffold` | Stands up a new project from an archetype and configures its gate | optional | 15 | 2533 | 3 | 3 |
| `editor` | The terminal input-line editor behind the REPL prompt | core | 10 | 2399 | 2 | 2 |
| `config` | tsforge.config.json, profiles, recipes, agent specs, and external plugins | core | 10 | 2337 | 7 | 8 |
| `(root)` | CLI entry, model registry, session persistence — the loose files in src/ | core | 6 | 2322 | 5 | 15 |
| `config` | tsforge.config.json, profiles, recipes, agent specs, and external plugins | core | 9 | 2250 | 6 | 8 |
| `reviewers` | Independent review panel that grades a change before it is trusted | optional | 9 | 2212 | 1 | 3 |
| `eval` | Run scoring, failure classification, and the quality judge | optional | 10 | 1817 | 4 | 4 |
| `files` | Reading, creating, and hash-anchored editing of workspace files | core | 9 | 1592 | 5 | 1 |
| `lib` | Shared primitives — fs, json, guards, scope globs, SSRF checks, clipboard | core | 18 | 1404 | 24 | 0 |
| `eval` | Run scoring, failure classification, and the quality judge | optional | 10 | 1906 | 4 | 4 |
| `files` | Reading, creating, and hash-anchored editing of workspace files | core | 9 | 1663 | 5 | 1 |
| `lib` | Shared primitives — fs, json, guards, scope globs, SSRF checks, clipboard | core | 18 | 1406 | 24 | 0 |
| `policy` | Decides which actions are allowed in the current mode before they run | core | 5 | 1259 | 5 | 3 |
| `gate` | Composes and runs the deterministic gate: linter, stages, tool paths | core | 10 | 1181 | 5 | 5 |
| `gate` | Composes and runs the deterministic gate: linter, stages, tool paths | core | 10 | 1188 | 5 | 5 |
| `architecture` ⚠️ | Derives this map from source so the docs cannot drift from the code | optional | 8 | 1118 | 0 | 0 |
| `lsp` | TypeScript language service powering navigation and write-time diagnostics | optional | 3 | 798 | 4 | 0 |
| `lsp` | TypeScript language service powering navigation and write-time diagnostics | optional | 3 | 803 | 4 | 0 |
| `infer-rules` | Scans a repo for its conventions and turns them into rule overrides | core | 7 | 787 | 5 | 2 |
| `mcp` | Model Context Protocol client that exposes external servers as tools | optional | 8 | 715 | 2 | 1 |
| `browser` | Headless Chromium oracle that render-checks a page as a gate stage | optional | 3 | 683 | 1 | 1 |
Expand Down Expand Up @@ -66,7 +66,7 @@ buries the ones someone can actually go and break.
| `cli` ↔ `render` | `cli/banner.ts:9` → `../render` | `render/command-menu.ts:2` → `../cli/commands` |
| `config` ↔ `rule-packs` | `config/external-plugins.ts:5` → `../rule-packs` | `rule-packs/index.ts:113` → `../config/plugin-fingerprint` |
| `editor` ↔ `render` | `editor/view.ts:2` → `../render/style` | `render/width.ts:1` → `../editor/segments` |
| `eval` ↔ `loop` | `eval/failure-class.ts:1` → `../loop/loop.types` | `loop/quality.ts:7` → `../eval` |
| `eval` ↔ `loop` | `eval/failure-class.ts:1` → `../loop/loop.types` | `loop/loop.types.ts:5` → `../eval/failure-class` |
| `inference` ↔ `loop` | `inference/wire.ts:10` → `../loop/context-hygiene` | `loop/assistant-message.ts:1` → `../inference` |
| `loop` ↔ `render` | `loop/worklist/panel.ts:3` → `../../render/frame/ansi-plain` | `render/agent-tree.ts:8` → `../loop/loop.types` |
| `loop` ↔ `self-harness` | `loop/feedback/rule-docs.ts:3` → `../../self-harness/overlay` | `self-harness/build-evidence.ts:3` → `../loop` |
Expand All @@ -82,7 +82,7 @@ everything stack-specific arrives through one of these.
| Seam | Declared | Files referencing it |
| --- | --- | --- |
| `IStackAdapter` | `loop/planning/stack-adapter.ts:14` | 5 |
| `IConventionProvider` | `loop/conventions-provider.ts:10` | 8 |
| `IConventionProvider` | `loop/conventions-provider.ts:10` | 12 |
| `IPlanSchema` | `loop/planning/plan-types.ts:46` | 6 |
| `IGate` | `gate/gate-runner.ts:25` | 14 |
| `IProductPlan` | `loop/planning/plan-types.ts:32` | 6 |
Expand All @@ -100,7 +100,7 @@ Async functions returning an exit code, declared under the CLI — the commands.
| `main` | `cli.ts:754` |
| `mapMode` | `cli.ts:481` |
| `recipesMode` | `cli.ts:500` |
| `repl` | `cli/repl.ts:662` |
| `repl` | `cli/repl.ts:668` |
| `reviewMode` | `cli.ts:181` |
| `runOnce` | `cli.ts:93` |
| `runTraceCommand` | `cli/repl-commands.ts:118` |
Expand Down
5 changes: 3 additions & 2 deletions packages/core/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ Rules are grouped by **adoption tier**. Use `profile` in `tsforge.config.json` t
- **react-component-architecture/max-hooks-per-file** [ERROR]: Flag query/hook modules that export more than N hooks. Same-kind modules pass the single-semantic-module rule but still grow into god files; this rule sets a hard ceiling so the split conversation happens early.
- **react-component-architecture/no-anonymous-useEffect** [ERROR]: Disallow anonymous arrow functions passed to useEffect — use a named function for debuggable stack traces.
- **react-component-architecture/no-component-invocation** [ERROR]: Disallow invoking React components as plain functions — use JSX (`<Header />`) instead of `{Header()}`.
- **react-component-architecture/no-cross-feature-imports** [ERROR]: Prevent imports across different features
- **react-component-architecture/no-cross-feature-imports** [ERROR]: Prevent imports across different features under src/features or src/views
- **react-component-architecture/no-derived-state-in-effect** [ERROR]: Disallow setting local state inside useEffect when the value can be derived during render (or memoized with useMemo).
- **react-component-architecture/no-jsx-computation** [ERROR]: Move complex computations out of JSX into hooks or helper functions
- **react-component-architecture/no-loading-text-use-skeleton** [ERROR]: Loading states must render a <Skeleton/>, not loading text or a spinner
Expand All @@ -128,9 +128,10 @@ Rules are grouped by **adoption tier**. Use `profile` in `tsforge.config.json` t
- **comment-hygiene/no-narration-comments** [ERROR]: Disallow narrative comments like 'Here we...', 'Now we...', 'First, we...'. These read as step-by-step prose and add no information a future reader can't get from the code itself. Often a tell that the comment was generated by an agent describing its own changes.
- **comment-hygiene/no-pr-reference-comments** [ERROR]: Disallow PR/issue references in comments. They belong in commit messages and PR descriptions — leaving them in source rots when the repo moves, the issue tracker migrates, or the numbering changes.
- **react-component-architecture/component-file-purity** [ERROR]: A component .tsx contains only imports and the component itself — types go to <feature>.types.ts, constants to <feature>.constants.ts, helpers to src/lib
- **react-component-architecture/component-folder-structure** [ERROR]: A component .tsx must live in src/views/<Feature>/components/ (feature component), src/components/ui/ (shared primitive), or be the view root src/views/<Feature>/index.tsx
- **react-component-architecture/component-folder-structure** [ERROR]: A component .tsx must live in src/views/<Feature>/components/ or src/features/<Feature>/components/ (feature component), src/components/ui/ (shared primitive), or be the view root src/views|features/<Feature>/index.tsx
- **react-component-architecture/no-inline-jsx-functions** [WARN]: Disallow inline function expressions in JSX attributes
- **react-component-architecture/no-state-in-component-body** [ERROR]: State hooks must be in .hooks.ts files, not directly in components
- **react-component-architecture/one-component-per-file** [ERROR]: One top-level React component per .tsx file — move extras to their own files
- **structured-logging/logger-not-console** [WARN]: Service modules should use the structured logger instead of `console.*` — console output is unstructured and hard to search.

### Tier: experimental
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/cli/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,7 @@ async function initReplSession(args: ICliArgs): Promise<{
32_768;
const report = makeReporter(logFile, id, id);
const profile = resolveCliProfile(args.profile);
const gatedBuild = autoGate !== undefined || accept.length > 0;
const config = {
provider,
cwd: args.dir,
Expand All @@ -560,6 +561,11 @@ async function initReplSession(args: ICliArgs): Promise<{
// enables framework rule-packs (React, etc.) as soon as the model writes them,
// instead of staying on the empty-dir `generic-ts` fallback. Absent ⇒ commandGate.
...(autoGate === undefined ? {} : { autoGate }),
// Gated interactive builds (auto-gate or accept) get drive-to-green + on-demand
// `check` — DeepSeek greenfield dogfood burned turns waiting for end-of-turn settle.
...(gatedBuild
? { executionMode: "drive-to-green" as const, offerCheck: true as const }
: {}),
...(resumed === null ? {} : { history: resumed.messages }),
// Opt into the SCOPED format janitor (replaces the old whole-repo `fix`): the loop's
// autoFixStep runs a strict eslint --fix + prettier over the files the model wrote
Expand Down Expand Up @@ -1143,6 +1149,7 @@ export async function repl(args: ICliArgs): Promise<number> {
planMode = false;
planDiscussed = false;
session.setPlanMode(false);
setMode("normal");
await persist();
echo(" ✓ plan approved — implementing\n");
await drive((opts) => session.send(PLAN_APPROVED_NOTE, opts));
Expand Down Expand Up @@ -1233,6 +1240,7 @@ export async function repl(args: ICliArgs): Promise<number> {
const clearConversation = async (): Promise<void> => {
// Rebuild the session with the current state (config is not reused;
// repl's /clear creates a fresh Session.create call)
spinner.resetClock();
const profile = resolveCliProfile(args.profile);
// Carry a still-unvalidated pre-pause edit across the rebuild so /clear does not
// silently drop the deferred gate: the gate fires on mutation state (`edited`),
Expand Down Expand Up @@ -1261,6 +1269,13 @@ export async function repl(args: ICliArgs): Promise<number> {
// once a manual /gate has taken over (autoGateActive false), so the rebuild
// never silently re-arms the auto gate over the user's command.
...autoGateCarry(autoGate, session.autoGateActive),
// Same gated-build contract as init — /clear must not drop on-demand `check`.
...(autoGate !== undefined || session.gate.length > 0
? {
executionMode: "drive-to-green" as const,
offerCheck: true as const,
}
: {}),
// Plain boolean (no branch): the constructor only seeds the flag when true.
pausedWithEdit: carryDeferredGate,
...(profile === undefined ? {} : { profile }),
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/config/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ export interface IProfileDefinition {
* re-export). These are off by default so tsforge stays adoptable on an EXISTING repo
* that has its own valid structure; they turn on when the project opts into the
* `opinionated` profile (tsforge.config.json or `--profile opinionated`) — e.g. a
* greenfield app where tsforge owns the tree. The strictness moat — the
* greenfield React app where tsforge owns the tree (boringstack scaffold seeds this;
* empty Vite scratch builds pass `--profile opinionated`). The strictness moat — the
* layout-AGNOSTIC best practices (no `as`, no `any`, no JSX computation, component-file
* purity, named JSX handlers, forwardRef display names, …) — is ON in every profile. */
export const STRUCTURE_RULES = [
"component-folder-structure",
"index-must-reexport-default",
"no-state-in-component-body",
"max-hooks-per-file",
"one-component-per-file",
] as const;

const structureOffOverrides = Object.fromEntries(
Expand Down Expand Up @@ -84,6 +86,7 @@ export const PROFILE_DEFINITIONS: Readonly<
"index-must-reexport-default": "error",
"forwardref-display-name": "error",
"max-hooks-per-file": "error",
"one-component-per-file": "error",
"prefer-early-return": "error",
},
},
Expand Down
83 changes: 83 additions & 0 deletions packages/core/src/config/seed-greenfield-profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Seed `profile: "opinionated"` so STRUCTURE_RULES gate for React greenfield.
*
* Call ONLY from React-owned entry points (e.g. boringstack scaffold handoff).
* Never from generic gate/session setup — non-React projects must not pay this tax.
* Empty Vite scratch builds opt in with `--profile opinionated` or a recipe profile.
*/
import { join } from "node:path";
import { isRecord } from "../lib/guards";

const CONFIG_FILE = "tsforge.config.json";

export type SeedGreenfieldProfileResult =
| { readonly seeded: false; readonly reason: string }
| { readonly seeded: true; readonly path: string };

function hasOwnProfile(root: Record<string, unknown>): boolean {
return typeof root.profile === "string" && root.profile.length > 0;
}

/** True when package.json lists react. */
async function packageJsonHasReact(cwd: string): Promise<boolean> {
const pkgFile = Bun.file(join(cwd, "package.json"));

if (!(await pkgFile.exists())) {
return false;
}

try {
const raw: unknown = await pkgFile.json();

if (!isRecord(raw)) {
return false;
}

const deps = isRecord(raw.dependencies) ? raw.dependencies : {};
const devDeps = isRecord(raw.devDependencies) ? raw.devDependencies : {};

return Object.hasOwn(deps, "react") || Object.hasOwn(devDeps, "react");
} catch {
return false;
}
}

/**
* Write `{ "profile": "opinionated" }` (merged) when cwd has react and no profile yet.
* Idempotent — never overwrites an explicit profile.
*/
export async function seedReactGreenfieldOpinionated(
cwd: string
): Promise<SeedGreenfieldProfileResult> {
if (!(await packageJsonHasReact(cwd))) {
return { seeded: false, reason: "no react dependency" };
}

const configPath = join(cwd, CONFIG_FILE);
const configFile = Bun.file(configPath);
let existing: Record<string, unknown> = {};

if (await configFile.exists()) {
try {
const parsed: unknown = JSON.parse(await configFile.text());

if (!isRecord(parsed)) {
return { seeded: false, reason: "existing config is not an object" };
}

existing = parsed;
} catch {
return { seeded: false, reason: "existing config is invalid JSON" };
}

if (hasOwnProfile(existing)) {
return { seeded: false, reason: "profile already set" };
}
}

const merged = { ...existing, profile: "opinionated" };

await Bun.write(configPath, `${JSON.stringify(merged, null, 2)}\n`);

return { seeded: true, path: CONFIG_FILE };
}
87 changes: 87 additions & 0 deletions packages/core/src/eval/failure-class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,3 +300,90 @@ export function classifyRun(

return { failureClass: classifyBehavior(signals), signals };
}

/** Empty signal tallies for a green mid-run settle (no event walk needed). */
function emptySignals(): IFailureSignals {
return {
repairs: 0,
salvages: 0,
editRejects: 0,
degenerated: false,
timedOut: false,
toolUseFailed: false,
tsErrors: 0,
lintErrors: 0,
missingModule: 0,
browser: 0,
build: 0,
};
}

/**
* Live-loop classifier: stamp the CURRENT gate from its ErrorSet (+ optional
* recent events for behavioral signals). Unlike `classifyRun`, a green gate is
* `none` even when the event stream has not yet emitted `done` — settle needs
* that before the done event exists.
*/
export function classifyFromGate(
gateErrors: ErrorSet,
events: readonly ILoopEvent[] = []
): IFailureSummary {
if (gateErrors.length === 0) {
return { failureClass: FAILURE_CLASS.none, signals: emptySignals() };
}

return classifyRun(events, gateErrors);
}

/** Forbidden rationalizations the model must not try for each failure class. */
const ATTRIBUTION_GUIDANCE: Record<FailureClass, string> = {
[FAILURE_CLASS.none]: "gate is green",
[FAILURE_CLASS.lintRule]:
"fix implementation; do not disable, skip, or weaken the rule, and do not " +
"move the call into an unallowlisted path without updating the rule defaults",
[FAILURE_CLASS.typeError]:
"fix types with guards/narrowing; do not cast around the error",
[FAILURE_CLASS.hallucinatedImport]:
"if the missing module is an npm package, install it (and `@types/*` if it ships no types) " +
"before more feature code; only create a local file when the import is a project-relative " +
"path — do not invent packages",
[FAILURE_CLASS.editReject]:
"fix the path/scope of the write; do not retry the same rejected target",
[FAILURE_CLASS.toolMalformed]:
"emit well-formed tool calls; do not narrate file contents as chat",
[FAILURE_CLASS.noProgress]:
"change strategy; if the block stays unclear, raise a hand rather than thrashing",
[FAILURE_CLASS.degeneration]:
"stop repeating; take one concrete next step or raise a hand",
[FAILURE_CLASS.timeout]:
"infrastructure timeout — raise a hand; do not invent a code fix",
[FAILURE_CLASS.routePhantom]:
"wire a real route/page; do not leave stubs that render blank",
[FAILURE_CLASS.browserFail]:
"fix the runtime/render failure; do not paper over it in tests alone",
[FAILURE_CLASS.buildFail]:
"fix the bundler/build error; do not ignore the build step",
[FAILURE_CLASS.unknown]:
"cause is unclear — prefer raising a hand over guessing",
};

/**
* One-line harness-owned attribution for gate feedback. Empty when the gate is
* green (`none`) so callers can prepend without a special case.
*/
export function attributionLeadIn(summary: {
readonly failureClass: FailureClass;
readonly detail?: string;
}): string {
if (summary.failureClass === FAILURE_CLASS.none) {
return "";
}

const detail =
summary.detail !== undefined && summary.detail.length > 0
? ` (${summary.detail})`
: "";
const guidance = ATTRIBUTION_GUIDANCE[summary.failureClass];

return `Harness attribution: ${summary.failureClass}${detail} — ${guidance}.`;
}
2 changes: 2 additions & 0 deletions packages/core/src/eval/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export { countLoc, countTaskLoc, type ITaskLoc } from "./loc";
export { analyzeEvents, type IRunMetrics } from "./metrics";
export {
classifyRun,
classifyFromGate,
attributionLeadIn,
FAILURE_CLASS,
type FailureClass,
type IFailureSummary,
Expand Down
Loading
Loading