@agjs/tsforge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/bin/tsforge.js +2 -0
  2. package/package.json +35 -0
  3. package/src/agent/agent.constants.ts +382 -0
  4. package/src/agent/agent.types.ts +34 -0
  5. package/src/agent/index.ts +4 -0
  6. package/src/agent/model-agent.ts +297 -0
  7. package/src/agent/tool-repair.ts +194 -0
  8. package/src/agent/tools.ts +190 -0
  9. package/src/browser/checks.ts +96 -0
  10. package/src/browser/index.ts +8 -0
  11. package/src/browser/oracle.ts +303 -0
  12. package/src/classify.ts +48 -0
  13. package/src/cli.ts +1333 -0
  14. package/src/config/config.constants.ts +9 -0
  15. package/src/config/flags.ts +32 -0
  16. package/src/config/index.ts +8 -0
  17. package/src/config/tsforge-config.ts +301 -0
  18. package/src/constitution/baseline.ts +257 -0
  19. package/src/detect-gate.ts +498 -0
  20. package/src/eval/eval.types.ts +36 -0
  21. package/src/eval/index.ts +3 -0
  22. package/src/eval/judge.ts +62 -0
  23. package/src/eval/score.ts +39 -0
  24. package/src/files/create.ts +22 -0
  25. package/src/files/edit.ts +193 -0
  26. package/src/files/files.constants.ts +11 -0
  27. package/src/files/files.types.ts +81 -0
  28. package/src/files/hashline-format.ts +110 -0
  29. package/src/files/hashline.ts +689 -0
  30. package/src/files/index.ts +19 -0
  31. package/src/index.ts +8 -0
  32. package/src/inference/index.ts +6 -0
  33. package/src/inference/inference.constants.ts +34 -0
  34. package/src/inference/inference.types.ts +123 -0
  35. package/src/inference/openai-compatible.ts +113 -0
  36. package/src/inference/stream-guard.ts +161 -0
  37. package/src/inference/stream.ts +370 -0
  38. package/src/inference/transport.ts +78 -0
  39. package/src/inference/wire.ts +0 -0
  40. package/src/lib/fs/fs.ts +126 -0
  41. package/src/lib/fs/fs.types.ts +5 -0
  42. package/src/lib/fs/index.ts +3 -0
  43. package/src/lib/fs/process.ts +146 -0
  44. package/src/lib/guards/guards.ts +9 -0
  45. package/src/lib/guards/index.ts +1 -0
  46. package/src/lib/json/index.ts +1 -0
  47. package/src/lib/json/json.ts +12 -0
  48. package/src/lib/scope/index.ts +2 -0
  49. package/src/lib/scope/scope.constants.ts +3 -0
  50. package/src/lib/scope/scope.ts +40 -0
  51. package/src/loop/astgrep-fix.ts +228 -0
  52. package/src/loop/feedback/feedback.ts +138 -0
  53. package/src/loop/feedback/index.ts +8 -0
  54. package/src/loop/feedback/meta-rule-docs.ts +41 -0
  55. package/src/loop/feedback/meta-rule-feedback.ts +61 -0
  56. package/src/loop/feedback/rule-docs.generated.json +112 -0
  57. package/src/loop/feedback/rule-docs.ts +342 -0
  58. package/src/loop/index.ts +19 -0
  59. package/src/loop/loop.constants.ts +68 -0
  60. package/src/loop/loop.types.ts +99 -0
  61. package/src/loop/prompt/index.ts +2 -0
  62. package/src/loop/prompt/project-map.ts +69 -0
  63. package/src/loop/prompt/prompt.ts +107 -0
  64. package/src/loop/quality.ts +174 -0
  65. package/src/loop/rule-docs.generated.json +367 -0
  66. package/src/loop/run-spec.ts +88 -0
  67. package/src/loop/run.ts +400 -0
  68. package/src/loop/session.ts +1410 -0
  69. package/src/loop/tools/add-dependency.ts +71 -0
  70. package/src/loop/tools/condense.ts +498 -0
  71. package/src/loop/tools/edit-hashline.ts +80 -0
  72. package/src/loop/tools/execute-tool.ts +80 -0
  73. package/src/loop/tools/file-ops.ts +323 -0
  74. package/src/loop/tools/index.ts +2 -0
  75. package/src/loop/tools/lsp-ops.ts +222 -0
  76. package/src/loop/tools/scaffold-routes.ts +68 -0
  77. package/src/loop/tools/scaffold-ui.ts +62 -0
  78. package/src/loop/tools/scaffold-web.ts +35 -0
  79. package/src/loop/tools/tool-context.ts +126 -0
  80. package/src/loop/ttsr-defaults.ts +53 -0
  81. package/src/loop/ttsr.ts +322 -0
  82. package/src/loop/turn.ts +856 -0
  83. package/src/lsp/index.ts +2 -0
  84. package/src/lsp/lsp.types.ts +56 -0
  85. package/src/lsp/service.ts +500 -0
  86. package/src/meta-rules/context.ts +195 -0
  87. package/src/meta-rules/index.ts +9 -0
  88. package/src/meta-rules/meta-rules.types.ts +47 -0
  89. package/src/meta-rules/parsers/package-json-parser.ts +51 -0
  90. package/src/meta-rules/registry.ts +37 -0
  91. package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
  92. package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
  93. package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
  94. package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
  95. package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
  96. package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
  97. package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
  98. package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
  99. package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
  100. package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
  101. package/src/meta-rules/runner.ts +64 -0
  102. package/src/models-config.ts +196 -0
  103. package/src/render/ansi.ts +289 -0
  104. package/src/render/banner.ts +113 -0
  105. package/src/render/box.ts +134 -0
  106. package/src/render/index.ts +7 -0
  107. package/src/render/markdown.ts +123 -0
  108. package/src/render/render.types.ts +21 -0
  109. package/src/render/stream-markdown.ts +128 -0
  110. package/src/render/style.ts +26 -0
  111. package/src/rule-packs/bullmq/index.ts +39 -0
  112. package/src/rule-packs/bullmq/rules/index.ts +7 -0
  113. package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
  114. package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
  115. package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
  116. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
  117. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
  118. package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
  119. package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
  120. package/src/rule-packs/bullmq/utils.ts +334 -0
  121. package/src/rule-packs/code-flow/index.ts +25 -0
  122. package/src/rule-packs/code-flow/rules/index.ts +3 -0
  123. package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
  124. package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
  125. package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
  126. package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
  127. package/src/rule-packs/comment-hygiene/index.ts +25 -0
  128. package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
  129. package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
  130. package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
  131. package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
  132. package/src/rule-packs/create-rule.ts +9 -0
  133. package/src/rule-packs/drizzle/index.ts +41 -0
  134. package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
  135. package/src/rule-packs/drizzle/rules/index.ts +8 -0
  136. package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
  137. package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
  138. package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
  139. package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
  140. package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
  141. package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
  142. package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
  143. package/src/rule-packs/drizzle/utils.ts +115 -0
  144. package/src/rule-packs/elysia/index.ts +43 -0
  145. package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
  146. package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
  147. package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
  148. package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
  149. package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
  150. package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
  151. package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
  152. package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
  153. package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
  154. package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
  155. package/src/rule-packs/env-access/index.ts +23 -0
  156. package/src/rule-packs/env-access/rules/index.ts +2 -0
  157. package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
  158. package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
  159. package/src/rule-packs/i18n-keys/index.ts +19 -0
  160. package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
  161. package/src/rule-packs/index.ts +139 -0
  162. package/src/rule-packs/jwt-cookies/index.ts +25 -0
  163. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
  164. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
  165. package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
  166. package/src/rule-packs/jwt-cookies/utils.ts +188 -0
  167. package/src/rule-packs/oauth-security/index.ts +25 -0
  168. package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
  169. package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
  170. package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
  171. package/src/rule-packs/oauth-security/utils.ts +127 -0
  172. package/src/rule-packs/react-component-architecture/index.ts +35 -0
  173. package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
  174. package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
  175. package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
  176. package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
  177. package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
  178. package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
  179. package/src/rule-packs/react-component-architecture/utils.ts +47 -0
  180. package/src/rule-packs/rule-packs.types.ts +18 -0
  181. package/src/rule-packs/structured-logging/index.ts +26 -0
  182. package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
  183. package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
  184. package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
  185. package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
  186. package/src/rule-packs/tanstack-query/index.ts +20 -0
  187. package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
  188. package/src/rule-packs/test-conventions/index.ts +23 -0
  189. package/src/rule-packs/test-conventions/rules/index.ts +2 -0
  190. package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
  191. package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
  192. package/src/rule-packs/utils.ts +142 -0
  193. package/src/session-store.ts +359 -0
  194. package/src/spec/generate-tests.ts +213 -0
  195. package/src/spec/index.ts +5 -0
  196. package/src/spec/parse.ts +152 -0
  197. package/src/spec/review-tests.ts +162 -0
  198. package/src/spec/spec.constants.ts +13 -0
  199. package/src/spec/spec.types.ts +79 -0
  200. package/src/stack-detection/detect.ts +246 -0
  201. package/src/stack-detection/index.ts +3 -0
  202. package/src/stack-detection/packs.ts +174 -0
  203. package/src/stack-detection/stack-detection.types.ts +47 -0
  204. package/src/validate/accept.ts +49 -0
  205. package/src/validate/errors.ts +35 -0
  206. package/src/validate/index.ts +12 -0
  207. package/src/validate/parse.ts +148 -0
  208. package/src/validate/run-tests.ts +59 -0
  209. package/src/validate/validate.ts +40 -0
  210. package/src/validate/validate.types.ts +52 -0
  211. package/src/web-components.ts +638 -0
  212. package/src/web-coverage.ts +89 -0
  213. package/src/web-routes.ts +151 -0
  214. package/src/web-templates.ts +1011 -0
  215. package/strict.eslint.config.mjs +84 -0
  216. package/strict.web.eslint.config.mjs +185 -0
package/src/cli.ts ADDED
@@ -0,0 +1,1333 @@
1
+ #!/usr/bin/env bun
2
+ import { join, isAbsolute } from "node:path";
3
+ import { appendFileSync, mkdirSync } from "node:fs";
4
+ import { createInterface } from "node:readline/promises";
5
+ import {
6
+ runTask,
7
+ RUN_STATUS,
8
+ Session,
9
+ PLAN_APPROVED_NOTE,
10
+ LOOP_LIMITS,
11
+ } from "./loop";
12
+ import {
13
+ PROVIDER_LIMITS,
14
+ OpenAICompatibleProvider,
15
+ type IOpenAICompatibleConfig,
16
+ } from "./inference";
17
+ import {
18
+ resolveActiveModel,
19
+ setActiveModel,
20
+ loadModelsConfig,
21
+ resolveApiKey,
22
+ type IModelEntry,
23
+ } from "./models-config";
24
+ import {
25
+ renderEvent,
26
+ renderMessage,
27
+ renderStatus,
28
+ welcomeBanner,
29
+ STYLE,
30
+ RESET,
31
+ } from "./render";
32
+ import type { ITask } from "./spec";
33
+ import type { Reporter, ILoopEvent } from "./loop";
34
+ import {
35
+ buildGate,
36
+ buildWebGate,
37
+ buildWebFix,
38
+ buildWebTypeGate,
39
+ buildWebTscCheck,
40
+ scaffoldWeb,
41
+ installWebDeps,
42
+ webGuidance,
43
+ } from "./detect-gate";
44
+ import type { WebFramework } from "./web-templates";
45
+ import { isRecord } from "./lib/guards";
46
+ import {
47
+ saveSession,
48
+ latestSession,
49
+ loadSession,
50
+ listSessions,
51
+ pruneSessions,
52
+ persistenceEnabled,
53
+ logsDir,
54
+ type ISessionRecord,
55
+ } from "./session-store";
56
+
57
+ /**
58
+ * The tsforge CLI — the product surface over the same engine the eval harness
59
+ * uses (see cli-product-direction). Like any agentic CLI: cd into a repo, run it,
60
+ * and talk. The agent reads/runs/edits the whole workspace by default.
61
+ *
62
+ * tsforge # interactive session in the current repo
63
+ * tsforge --dir ~/app # ...in another repo
64
+ * tsforge "fix the build" # interactive, with that as the first message
65
+ * tsforge "fix X" --accept "npm test" # one-shot: drive to green, then exit
66
+ * tsforge --continue # resume the most recent session for this dir
67
+ *
68
+ * The eval-only knobs are now OPTIONAL refinements, never required:
69
+ * --files "<globs>" narrow the editable scope (default: the whole workspace)
70
+ * --accept "<cmd>" a gate that confirms "done" (default: stop when the model
71
+ * stops — like any chat agent). With a gate set, tsforge's
72
+ * deterministic check enforces correctness; it can't be faked.
73
+ * --log record the full event stream (reasoning, every file the
74
+ * agent writes, gate verdicts, timing) as JSONL to an
75
+ * auto-named ~/.tsforge/logs/<timestamp>-<id>.jsonl — the
76
+ * record to evaluate runs and see where the model got stuck.
77
+ * Slash commands (/help, /clear, /exit) follow the standard harness UX. Provider
78
+ * via TSFORGE_* env.
79
+ */
80
+ export interface ICliArgs {
81
+ /** Empty ⇒ interactive REPL; non-empty ⇒ one-shot task. */
82
+ task: string;
83
+ dir: string;
84
+ files: string[];
85
+ accept: string;
86
+ /** Resume the most recent saved session for this dir (`--continue` / `-c`). */
87
+ continue: boolean;
88
+ /** Resume a specific session by id (`--resume <id>`). */
89
+ resumeId: string;
90
+ /** Skip auto-detecting a gate from the project (`--no-gate`). */
91
+ noGate: boolean;
92
+ /** An HTML file to render-check in headless chromium as part of the gate (`--browser`). */
93
+ browser: string;
94
+ /** Scaffold + gate a web app: skeleton + tsc/eslint/build/browser ladder (`--web`). */
95
+ web: boolean;
96
+ /** Append the full event stream (reasoning, tool writes, gate verdicts) as JSONL
97
+ * to an auto-named file under ~/.tsforge/logs/ for later evaluation (`--log`). */
98
+ log: boolean;
99
+ /** Plan mode: a from-scratch build pauses after the design phase to show its
100
+ * plan for review/edit before implementing (`--plan`; also toggled by /plan). */
101
+ plan: boolean;
102
+ }
103
+
104
+ const BOOL_FLAGS: Record<
105
+ string,
106
+ "continue" | "noGate" | "web" | "log" | "plan"
107
+ > = {
108
+ "--continue": "continue",
109
+ "-c": "continue",
110
+ "--no-gate": "noGate",
111
+ "--web": "web",
112
+ "--log": "log",
113
+ "--plan": "plan",
114
+ };
115
+
116
+ const VALUE_FLAGS = new Set([
117
+ "--dir",
118
+ "--files",
119
+ "--accept",
120
+ "--gate",
121
+ "--browser",
122
+ "--resume",
123
+ ]);
124
+
125
+ /** Parse argv (without the tsforge binary name). Always succeeds — mode is decided in main. */
126
+ export function parseArgs(argv: readonly string[]): ICliArgs {
127
+ const positional: string[] = [];
128
+ const out: ICliArgs = {
129
+ task: "",
130
+ dir: ".",
131
+ files: [],
132
+ accept: "",
133
+ continue: false,
134
+ resumeId: "",
135
+ noGate: false,
136
+ browser: "",
137
+ web: false,
138
+ log: false,
139
+ plan: false,
140
+ };
141
+
142
+ for (let i = 0; i < argv.length; i += 1) {
143
+ const arg = argv[i];
144
+
145
+ if (arg === undefined) {
146
+ continue;
147
+ }
148
+
149
+ const boolKey = BOOL_FLAGS[arg];
150
+
151
+ if (boolKey !== undefined) {
152
+ out[boolKey] = true;
153
+ } else if (VALUE_FLAGS.has(arg) && argv[i + 1] !== undefined) {
154
+ applyValueFlag(arg, argv[i + 1] ?? "", out);
155
+ i += 1;
156
+ } else if (!VALUE_FLAGS.has(arg)) {
157
+ positional.push(arg);
158
+ }
159
+ }
160
+
161
+ out.task = positional.join(" ").trim();
162
+ out.dir = isAbsolute(out.dir) ? out.dir : join(process.cwd(), out.dir);
163
+
164
+ return out;
165
+ }
166
+
167
+ /** Assign one `--flag value` into the args (mutates `out`). */
168
+ function applyValueFlag(flag: string, value: string, out: ICliArgs): void {
169
+ if (flag === "--dir") {
170
+ out.dir = value;
171
+ } else if (flag === "--files") {
172
+ out.files = value
173
+ .split(",")
174
+ .map((s) => s.trim())
175
+ .filter((s) => s.length > 0);
176
+ } else if (flag === "--browser") {
177
+ out.browser = value;
178
+ } else if (flag === "--resume") {
179
+ out.resumeId = value;
180
+ } else {
181
+ out.accept = value; // --accept / --gate
182
+ }
183
+ }
184
+
185
+ // Default editable scope: the whole workspace — like any agentic CLI, the agent
186
+ // may edit any file. `--files` only NARROWS this (a safety/eval tripwire); it's
187
+ // never required. `**/*` matches top-level and nested paths alike.
188
+ const WHOLE_REPO = ["**/*"];
189
+
190
+ /** Resolve the editable scope: an explicit `--files` narrowing, else the whole repo. */
191
+ function scopeOf(args: ICliArgs): string[] {
192
+ return args.files.length > 0 ? args.files : WHOLE_REPO;
193
+ }
194
+
195
+ /** One-shot mode = a task PLUS a gate to drive to green; else interactive. */
196
+ export function isOneShot(args: ICliArgs): boolean {
197
+ return args.task.length > 0 && args.accept.length > 0;
198
+ }
199
+
200
+ /** A unique-enough id for a new session (time + a little randomness). */
201
+ function newSessionId(): string {
202
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
203
+ }
204
+
205
+ /** Human label for an editable scope (the whole-repo default reads nicer). */
206
+ function scopeLabel(files: string[]): string {
207
+ return files.length === 1 && files[0] === "**/*"
208
+ ? "entire workspace"
209
+ : files.join(", ");
210
+ }
211
+
212
+ /** The host:port of an API base URL, for the banner (falls back to the raw url). */
213
+ function hostOf(baseUrl: string): string {
214
+ try {
215
+ return new URL(baseUrl).host;
216
+ } catch {
217
+ return baseUrl;
218
+ }
219
+ }
220
+
221
+ /** The active model id + endpoint host, from a wire-config (provider.config) or a
222
+ * registry entry — both carry `model` + `baseUrl`. */
223
+ function modelInfo(src: { model: string; baseUrl: string }): {
224
+ model: string;
225
+ endpoint: string;
226
+ } {
227
+ return { model: src.model, endpoint: hostOf(src.baseUrl) };
228
+ }
229
+
230
+ /** The model's real context window, read from the server's `/models`
231
+ * (`max_model_len` — vLLM/OpenAI-compatible). Best-effort: undefined if the
232
+ * endpoint is unreachable or doesn't report it (caller falls back). 3s cap so a
233
+ * dead endpoint can't stall CLI startup. */
234
+ async function detectContextWindow(
235
+ entry: IModelEntry
236
+ ): Promise<number | undefined> {
237
+ const headers: Record<string, string> = {};
238
+ const key = resolveApiKey(entry);
239
+
240
+ if (key !== undefined) {
241
+ headers.authorization = `Bearer ${key}`;
242
+ }
243
+
244
+ try {
245
+ const res = await fetch(`${entry.baseUrl}/models`, {
246
+ headers,
247
+ signal: AbortSignal.timeout(3000),
248
+ });
249
+
250
+ if (!res.ok) {
251
+ return undefined;
252
+ }
253
+
254
+ const data: unknown = await res.json();
255
+
256
+ if (!isRecord(data) || !Array.isArray(data.data)) {
257
+ return undefined;
258
+ }
259
+
260
+ const entries = data.data.filter(isRecord);
261
+ const match = entries.find((e) => e.id === entry.model) ?? entries[0];
262
+ const len = match?.max_model_len;
263
+
264
+ return typeof len === "number" && Number.isFinite(len) ? len : undefined;
265
+ } catch {
266
+ return undefined;
267
+ }
268
+ }
269
+
270
+ function frameworkLabel(framework: WebFramework): string {
271
+ return framework === "react"
272
+ ? "Vite + React + shadcn/ui + TanStack"
273
+ : "Vite + TypeScript + Tailwind";
274
+ }
275
+
276
+ /** Lay down a stack's skeleton and install its dependencies, reporting progress —
277
+ * the model can't build until deps resolve. */
278
+ async function setUpWebProject(
279
+ dir: string,
280
+ framework: WebFramework
281
+ ): Promise<void> {
282
+ await scaffoldWeb(dir, framework);
283
+ process.stdout.write(` ↳ installing ${frameworkLabel(framework)}…\n`);
284
+
285
+ const ok = await installWebDeps(dir);
286
+
287
+ process.stdout.write(
288
+ ok
289
+ ? " ↳ dependencies ready\n"
290
+ : " ⚠ dependency install failed — run `bun install` yourself\n"
291
+ );
292
+ }
293
+
294
+ /** Parse a numeric env var, returning undefined for unset/blank/non-numeric
295
+ * input (never NaN — a NaN reaching the provider serializes to `null` in the
296
+ * request body and the model request fails confusingly). */
297
+ function envNumber(name: string): number | undefined {
298
+ const raw = process.env[name];
299
+
300
+ if (raw === undefined || raw.trim().length === 0) {
301
+ return undefined;
302
+ }
303
+
304
+ const value = Number(raw);
305
+
306
+ return Number.isFinite(value) ? value : undefined;
307
+ }
308
+
309
+ /** Wire-config from a registry entry: API key resolved at use time (inline or
310
+ * via apiKeyEnv); env still tunes maxTokens/penalty. Shared by initial
311
+ * construction, `/model` hot-swap, and the interactive eval script — so they
312
+ * all behave identically. */
313
+ export function providerConfig(entry: IModelEntry): IOpenAICompatibleConfig {
314
+ const repetitionPenalty = envNumber("TSFORGE_REPETITION_PENALTY");
315
+
316
+ return {
317
+ baseUrl: entry.baseUrl,
318
+ model: entry.model,
319
+ apiKey: resolveApiKey(entry),
320
+ maxTokens:
321
+ entry.maxTokens ??
322
+ envNumber("TSFORGE_MAX_TOKENS") ??
323
+ PROVIDER_LIMITS.maxTokens,
324
+ // OFF by default: a global repetition penalty also penalizes the rigid,
325
+ // repetitive tool-call JSON tokens, which pushes the model to NARRATE
326
+ // instead of emitting tool calls (→ no files written). The StreamGuard is
327
+ // the targeted loop protection. Opt in only to experiment.
328
+ ...(repetitionPenalty === undefined ? {} : { repetitionPenalty }),
329
+ };
330
+ }
331
+
332
+ function makeProvider(entry: IModelEntry): OpenAICompatibleProvider {
333
+ return new OpenAICompatibleProvider(providerConfig(entry));
334
+ }
335
+
336
+ /** Print the model registry with ★ on the active one (the `/model` listing). */
337
+ async function listModels(
338
+ provider: OpenAICompatibleProvider,
339
+ activeName: string
340
+ ): Promise<void> {
341
+ const cfg = await loadModelsConfig();
342
+ const current = modelInfo(provider.config);
343
+
344
+ process.stdout.write(
345
+ ` active: ${activeName} — ${current.model} @ ${current.endpoint}\n`
346
+ );
347
+
348
+ for (const [name, e] of Object.entries(cfg.models)) {
349
+ const mark = name === activeName ? "★" : " ";
350
+
351
+ process.stdout.write(
352
+ ` ${mark} ${name} ${e.model} @ ${hostOf(e.baseUrl)}\n`
353
+ );
354
+ }
355
+
356
+ if (activeName === "env") {
357
+ process.stdout.write(
358
+ " (TSFORGE_* env is overriding the registry — unset it to use /model)\n"
359
+ );
360
+ }
361
+
362
+ process.stdout.write(" switch with: /model <name>\n");
363
+ }
364
+
365
+ /** Handle `/model [name]`: no arg lists the registry; a name persists it as active
366
+ * and HOT-SWAPS the live provider. Returns the (possibly updated) active name +
367
+ * context window for the caller to thread back into the REPL state. */
368
+ async function runModelCommand(opts: {
369
+ arg: string;
370
+ provider: OpenAICompatibleProvider;
371
+ activeName: string;
372
+ fallbackEntry: IModelEntry;
373
+ contextWindow: number;
374
+ }): Promise<{ activeName: string; contextWindow: number }> {
375
+ const { arg, provider, activeName, fallbackEntry, contextWindow } = opts;
376
+ const wanted = arg.trim();
377
+
378
+ if (wanted.length === 0) {
379
+ await listModels(provider, activeName);
380
+
381
+ return { activeName, contextWindow };
382
+ }
383
+
384
+ try {
385
+ const next = await setActiveModel(wanted);
386
+ const entry = next.models[wanted] ?? fallbackEntry;
387
+
388
+ provider.reconfigure(providerConfig(entry));
389
+
390
+ const window =
391
+ entry.contextWindow ??
392
+ (await detectContextWindow(entry)) ??
393
+ contextWindow;
394
+ const info = modelInfo(provider.config);
395
+
396
+ process.stdout.write(
397
+ ` ✓ switched to ${wanted} — ${info.model} @ ${info.endpoint} (context ${String(window)})\n`
398
+ );
399
+
400
+ return { activeName: wanted, contextWindow: window };
401
+ } catch (err) {
402
+ process.stdout.write(
403
+ ` ${err instanceof Error ? err.message : String(err)}\n`
404
+ );
405
+
406
+ return { activeName, contextWindow };
407
+ }
408
+ }
409
+
410
+ /** List saved sessions for a directory (the `/sessions` command). */
411
+ async function printSessions(dir: string): Promise<void> {
412
+ const sessions = await listSessions(dir);
413
+
414
+ if (sessions.length === 0) {
415
+ process.stdout.write("no saved sessions for this directory\n");
416
+
417
+ return;
418
+ }
419
+
420
+ for (const s of sessions) {
421
+ const firstUser = s.messages.find((m) => m.role === "user")?.content ?? "";
422
+ const snippet = firstUser.slice(0, 48).replace(/\s+/g, " ");
423
+
424
+ process.stdout.write(
425
+ ` ${s.id} ${String(s.messages.length).padStart(3)} msgs ${snippet}\n`
426
+ );
427
+ }
428
+ }
429
+
430
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
431
+ const SPINNER_TICK_MS = 120;
432
+ const ERASE_LINE = `\r${String.fromCharCode(27)}[2K`;
433
+
434
+ /** Animated activity line (`⠋ thinking · 12s`) for the silent stretches of a
435
+ * turn — hidden chain-of-thought, prompt processing, a slow first token. TTY
436
+ * only. Any rendered event clears it before printing (the next tick redraws),
437
+ * so it never interleaves with streamed text or boxes. */
438
+ function makeSpinner(): {
439
+ start: () => void;
440
+ clear: () => void;
441
+ stop: () => void;
442
+ setLabel: (label: string) => void;
443
+ } {
444
+ let timer: ReturnType<typeof setInterval> | null = null;
445
+ let startedAt = 0;
446
+ let frame = 0;
447
+ let drawn = false;
448
+ let label = "thinking";
449
+
450
+ const clear = (): void => {
451
+ if (drawn) {
452
+ process.stdout.write(ERASE_LINE);
453
+ drawn = false;
454
+ }
455
+ };
456
+
457
+ const tick = (): void => {
458
+ const secs = Math.round((performance.now() - startedAt) / 1000);
459
+
460
+ frame = (frame + 1) % SPINNER_FRAMES.length;
461
+ process.stdout.write(
462
+ `${ERASE_LINE} ${STYLE.dim}${SPINNER_FRAMES[frame] ?? ""} ${label} · ${secs}s${RESET}`
463
+ );
464
+ drawn = true;
465
+ };
466
+
467
+ return {
468
+ start: (): void => {
469
+ if (!process.stdout.isTTY || timer !== null) {
470
+ return;
471
+ }
472
+
473
+ label = "thinking";
474
+ startedAt = performance.now();
475
+ timer = setInterval(tick, SPINNER_TICK_MS);
476
+ },
477
+ clear,
478
+ stop: (): void => {
479
+ if (timer !== null) {
480
+ clearInterval(timer);
481
+ timer = null;
482
+ }
483
+
484
+ clear();
485
+ },
486
+ setLabel: (l: string): void => {
487
+ label = l;
488
+ },
489
+ };
490
+ }
491
+
492
+ const spinner = makeSpinner();
493
+
494
+ /** What the spinner should say given the latest event — the activity line
495
+ * follows the turn's phase instead of claiming "thinking" during a gate run
496
+ * or a dependency install. Null = keep the current label. */
497
+ export function spinnerPhase(event: ILoopEvent): string | null {
498
+ if (event.kind === "token") {
499
+ if (event.channel === "tool") {
500
+ return "writing";
501
+ }
502
+
503
+ return event.channel === "reasoning" ? "thinking" : null;
504
+ }
505
+
506
+ if (event.kind === "run" || event.kind === "validated") {
507
+ return "checking";
508
+ }
509
+
510
+ if (event.kind === "tool" && /install/i.test(event.message)) {
511
+ return "installing deps";
512
+ }
513
+
514
+ return event.kind === "cycle" ? "thinking" : null;
515
+ }
516
+
517
+ const render: Reporter = (event) => {
518
+ const phase = spinnerPhase(event);
519
+
520
+ if (phase !== null) {
521
+ spinner.setLabel(phase);
522
+ }
523
+
524
+ const out = renderEvent(event, { color: true });
525
+
526
+ if (out.length > 0) {
527
+ spinner.clear();
528
+ process.stdout.write(out);
529
+ }
530
+ };
531
+
532
+ /** Reporter that renders to the terminal AND, when `--log <file>` is set, appends
533
+ * the full event stream as JSONL (one event per line, timestamped) for later
534
+ * evaluation — the durable record of what the agent did: its reasoning, every
535
+ * file it wrote, the gate verdicts, and the loops it got stuck in. Append-only
536
+ * (NOT overwritten like the session JSON), and unredacted — it's an opt-in local
537
+ * debug artifact. Logging failures never break the session. */
538
+ function makeReporter(logFile: string): Reporter {
539
+ if (logFile.length === 0) {
540
+ return render;
541
+ }
542
+
543
+ return (event) => {
544
+ render(event);
545
+
546
+ try {
547
+ appendFileSync(
548
+ logFile,
549
+ `${JSON.stringify({ t: Date.now(), ...event })}\n`
550
+ );
551
+ } catch {
552
+ // A logging failure must never interrupt the session.
553
+ }
554
+ };
555
+ }
556
+
557
+ /** Resolve the run-log file when `--log` is set: an auto-named, timestamped JSONL
558
+ * under ~/.tsforge/logs/ (created if needed), so logs are always in one findable
559
+ * place and you never specify a path. Empty string = logging off. */
560
+ function resolveLogPath(id: string, enabled: boolean): string {
561
+ if (!enabled) {
562
+ return "";
563
+ }
564
+
565
+ const dir = logsDir();
566
+
567
+ mkdirSync(dir, { recursive: true });
568
+
569
+ const stamp = new Date()
570
+ .toISOString()
571
+ .replace(/[:T]/g, "-")
572
+ .replace(/\..+$/, "");
573
+
574
+ return join(dir, `${stamp}-${id}.jsonl`);
575
+ }
576
+
577
+ /** One-shot: drive a single task to green, then exit. */
578
+ async function runOnce(args: ICliArgs): Promise<number> {
579
+ const task: ITask = {
580
+ id: "cli",
581
+ intent: args.task,
582
+ accept: args.accept,
583
+ files: scopeOf(args),
584
+ context: [],
585
+ };
586
+
587
+ const logFile = resolveLogPath("cli", args.log);
588
+
589
+ if (logFile.length > 0) {
590
+ process.stdout.write(` ↳ logging this run to ${logFile}\n`);
591
+ }
592
+
593
+ const thinkingTokenBudget = envNumber("TSFORGE_THINKING_BUDGET");
594
+ const { entry } = await resolveActiveModel();
595
+ const result = await runTask(task, args.dir, makeProvider(entry), {
596
+ onEvent: makeReporter(logFile),
597
+ ...(thinkingTokenBudget === undefined ? {} : { thinkingTokenBudget }),
598
+ });
599
+ const ok = result.status === RUN_STATUS.done;
600
+
601
+ process.stdout.write(
602
+ `\n${ok ? "✓ done" : `✗ ${result.status}`} in ${String(result.cycles)} turn(s)\n`
603
+ );
604
+
605
+ return ok ? 0 : 1;
606
+ }
607
+
608
+ /** Wide approval — the staged-web checkpoint explicitly prompted "type
609
+ * 'approve'", so casual yeses count there. */
610
+ export function isApproval(line: string): boolean {
611
+ return /^(approve|approved|ok|okay|yes|y|go|lgtm)\.?$/i.test(line.trim());
612
+ }
613
+
614
+ /** Narrow approval — GENERAL plan mode, where the model asks clarifying
615
+ * questions: a "yes" may ANSWER a question, so only unambiguous approval
616
+ * words exit the mode and start implementing. */
617
+ export function isPlanApproval(line: string): boolean {
618
+ return /^(approve|approved|go|lgtm|implement)[.!]?$/i.test(line.trim());
619
+ }
620
+
621
+ const HELP = [
622
+ "Commands:",
623
+ " /help show this help",
624
+ " /compact summarize the conversation to free up context",
625
+ " /clear reset the conversation (keeps the workspace + gate)",
626
+ " /plan toggle plan mode (read-only: explore → clarify → plan; 'approve' implements)",
627
+ " /gate <cmd> set the gate command (empty to clear)",
628
+ " /files <globs> set the editable scope (comma-separated; empty = all)",
629
+ " /model [name] list configured models (★ active), or switch to <name>",
630
+ " /sessions list saved sessions (resume one with: tsforge --resume <id>)",
631
+ " /cost rough conversation size (messages + ~tokens)",
632
+ " /exit, /quit leave the session",
633
+ "",
634
+ "Anything else is sent to the agent. It works with its tools; when it stops,",
635
+ 'the gate (if set) confirms "done".',
636
+ "While it's working: type a message to STEER the next turn (e.g. 'use Tailwind');",
637
+ "Ctrl-C interrupts the current run.",
638
+ ].join("\n");
639
+
640
+ /** The session status line — distinguishes off / new / resumed. */
641
+ function sessionLine(id: string, resumed: ISessionRecord | null): string {
642
+ if (!persistenceEnabled()) {
643
+ return " session: not saved (TSFORGE_NO_PERSIST)";
644
+ }
645
+
646
+ return resumed === null
647
+ ? ` session: new (${id})`
648
+ : ` session: resumed ${resumed.messages.length} message(s)`;
649
+ }
650
+
651
+ /** Print the welcome banner, session info, and (when resuming) the prior transcript. */
652
+ function printHeader(info: {
653
+ dir: string;
654
+ id: string;
655
+ gateLabel: string;
656
+ files: string[];
657
+ resumed: ISessionRecord | null;
658
+ model: { model: string; endpoint: string };
659
+ }): void {
660
+ const { dir, id, gateLabel, files, resumed, model } = info;
661
+
662
+ process.stdout.write(welcomeBanner(model));
663
+ process.stdout.write(
664
+ [
665
+ ` cwd: ${dir}`,
666
+ ` scope: ${scopeLabel(files)}`,
667
+ ` gate: ${gateLabel}`,
668
+ sessionLine(id, resumed),
669
+ " /help for commands, /exit to quit",
670
+ "",
671
+ ].join("\n")
672
+ );
673
+
674
+ if (resumed === null) {
675
+ return;
676
+ }
677
+
678
+ // Replay the prior conversation so a resumed session has visible context.
679
+ process.stdout.write("\n── resuming conversation ──\n");
680
+
681
+ for (const message of resumed.messages) {
682
+ process.stdout.write(renderMessage(message, { color: true }));
683
+ }
684
+
685
+ process.stdout.write("\n──────────────────────────\n");
686
+ }
687
+
688
+ // tsforge's bundled browser-check script (headless-chromium render oracle).
689
+ const BROWSER_CHECK = join(
690
+ import.meta.dir,
691
+ "..",
692
+ "scripts",
693
+ "browser-check.ts"
694
+ );
695
+
696
+ function browserCheckCommand(htmlFile: string): string {
697
+ return `bun "${BROWSER_CHECK}" "${htmlFile}"`;
698
+ }
699
+
700
+ /**
701
+ * Resolve the session's gate + label. Starts from the base gate (resumed /
702
+ * explicit / auto strict-TS), then appends a `--browser` render check when asked
703
+ * — so a web build is verified to actually RUN, not just type-check.
704
+ */
705
+ async function resolveGate(
706
+ args: ICliArgs,
707
+ resumed: ISessionRecord | null
708
+ ): Promise<{ accept: string; gateLabel: string }> {
709
+ const base = await baseGate(args, resumed);
710
+
711
+ if (args.browser.length === 0) {
712
+ return base;
713
+ }
714
+
715
+ const browser = browserCheckCommand(args.browser);
716
+
717
+ return {
718
+ accept: base.accept.length > 0 ? `${base.accept} && ${browser}` : browser,
719
+ gateLabel:
720
+ base.accept.length > 0
721
+ ? `${base.gateLabel} + browser render`
722
+ : "browser render",
723
+ };
724
+ }
725
+
726
+ /** The base gate: a resumed session's gate wins, then explicit `--accept`, then
727
+ * `--no-gate` (off), else tsforge's auto gate (strict-TS / project lint). */
728
+ async function baseGate(
729
+ args: ICliArgs,
730
+ resumed: ISessionRecord | null
731
+ ): Promise<{ accept: string; gateLabel: string }> {
732
+ if (resumed !== null) {
733
+ const label = resumed.accept.length > 0 ? resumed.accept : "none";
734
+
735
+ return { accept: resumed.accept, gateLabel: label };
736
+ }
737
+
738
+ if (args.accept.length > 0) {
739
+ return { accept: args.accept, gateLabel: args.accept };
740
+ }
741
+
742
+ if (args.web) {
743
+ const web = buildWebGate("react");
744
+
745
+ return { accept: web.command, gateLabel: web.label };
746
+ }
747
+
748
+ if (args.noGate) {
749
+ return { accept: "", gateLabel: "none (--no-gate)" };
750
+ }
751
+
752
+ const { detectStack } = await import("./stack-detection");
753
+ const { loadTsforgeConfig, resolveActivePacks, normalizeRuleOverrides } =
754
+ await import("./config/tsforge-config");
755
+
756
+ const stackProfile = await detectStack(args.dir);
757
+ const config = await loadTsforgeConfig(args.dir);
758
+ const activePacks = resolveActivePacks(stackProfile.packs, config);
759
+ const ruleOverrides = normalizeRuleOverrides(config);
760
+
761
+ const auto = await buildGate(
762
+ args.dir,
763
+ activePacks,
764
+ Object.keys(ruleOverrides).length > 0 ? ruleOverrides : undefined
765
+ );
766
+
767
+ return { accept: auto.command, gateLabel: auto.label };
768
+ }
769
+
770
+ /** Interactive REPL: a persistent gate-anchored conversation. */
771
+ async function repl(args: ICliArgs): Promise<number> {
772
+ // The active model comes from the registry (~/.tsforge/models.json) unless an
773
+ // explicit TSFORGE_* env overrides it; `/model <name>` switches it live.
774
+ const activeModel = await resolveActiveModel();
775
+ const provider = makeProvider(activeModel.entry);
776
+ let activeName = activeModel.name;
777
+
778
+ // Best-effort cleanup of stale sessions on every launch.
779
+ await pruneSessions();
780
+
781
+ // --resume <id> loads a specific session; --continue the newest for this dir.
782
+ const resumed =
783
+ args.resumeId.length > 0
784
+ ? await loadSession(args.resumeId)
785
+ : args.continue
786
+ ? await latestSession(args.dir)
787
+ : null;
788
+
789
+ if ((args.continue || args.resumeId.length > 0) && resumed === null) {
790
+ process.stdout.write("(no matching saved session — starting fresh)\n");
791
+ }
792
+
793
+ // --web: lay down the opinionated skeleton before resolving the gate.
794
+ if (args.web && resumed === null) {
795
+ await setUpWebProject(args.dir, "react");
796
+ }
797
+
798
+ const id = resumed?.id ?? newSessionId();
799
+ const { accept, gateLabel } = await resolveGate(args, resumed);
800
+ const files = resumed !== null ? resumed.files : scopeOf(args);
801
+ const logFile = resolveLogPath(id, args.log);
802
+
803
+ if (logFile.length > 0) {
804
+ process.stdout.write(` ↳ logging this run to ${logFile}\n`);
805
+ }
806
+
807
+ const thinkingTokenBudget = envNumber("TSFORGE_THINKING_BUDGET");
808
+ // Auto-compaction threshold (fraction of the window); session default 0.8.
809
+ const autoCompactAt = envNumber("TSFORGE_COMPACT_AT");
810
+ // The model's real context window: explicit env wins, else ask the server
811
+ // (max_model_len), else a conservative fallback. Drives the status gauge AND
812
+ // auto-compaction (the session compacts before a send once it nears the window).
813
+ // `let` so `/model` can refresh the gauge when switching to a model with a
814
+ // different window. Per-entry contextWindow wins, then explicit env, then the
815
+ // server's max_model_len, then a conservative fallback.
816
+ let contextWindow =
817
+ activeModel.entry.contextWindow ??
818
+ envNumber("TSFORGE_CONTEXT_WINDOW") ??
819
+ (await detectContextWindow(provider.config)) ??
820
+ 32_768;
821
+ const report = makeReporter(logFile);
822
+ const config = {
823
+ provider,
824
+ cwd: args.dir,
825
+ files,
826
+ accept,
827
+ contextWindow,
828
+ report,
829
+ ...(resumed === null ? {} : { history: resumed.messages }),
830
+ // --web pre-scaffolds the project above, so it gets the web gate/guidance
831
+ // directly. EVERY OTHER interactive session offers `scaffold_web` (+ the
832
+ // ui/routes tools that ride along) so the AGENT can decide mid-conversation
833
+ // that a request is a from-scratch web app — this flag is what puts the tool
834
+ // in the model's list; setSetupWeb() below only wires its callback.
835
+ ...(args.web
836
+ ? {
837
+ guidance: webGuidance("react"),
838
+ fix: buildWebFix("react"),
839
+ incrementalCheck: buildWebTscCheck(),
840
+ }
841
+ : { scaffoldWeb: true }),
842
+ ...(thinkingTokenBudget === undefined ? {} : { thinkingTokenBudget }),
843
+ ...(autoCompactAt === undefined ? {} : { autoCompactAt }),
844
+ // Thinking OFF for interactive replies so they STREAM immediately instead of
845
+ // stalling on a long hidden chain-of-thought (qwen-local defaults thinking on).
846
+ // The session still flips thinking ON automatically while repairing gate errors.
847
+ enableThinking: false,
848
+ };
849
+
850
+ let session = await Session.create(config);
851
+
852
+ // A self-describing run-meta line at the top of the --log so the analyzer knows
853
+ // which model / context window the metrics are against (the thread's advice:
854
+ // many "model failures" are really quant/config failures — record the config).
855
+ report({
856
+ kind: "start",
857
+ task: "session",
858
+ message: `model ${modelInfo(provider.config).model} · context window ${contextWindow}`,
859
+ model: modelInfo(provider.config).model,
860
+ contextWindow,
861
+ });
862
+
863
+ const persist = async (): Promise<void> => {
864
+ await saveSession({
865
+ id,
866
+ cwd: args.dir,
867
+ // The LIVE gate/scope — not the startup constants. /gate, /files, and a web
868
+ // scaffold all mutate these mid-session; persisting the originals would
869
+ // silently restore stale settings on --continue. See P2 review.
870
+ accept: session.gate,
871
+ files: session.scope,
872
+ updatedAt: Date.now(),
873
+ planMode,
874
+ messages: [...session.messages],
875
+ });
876
+ };
877
+
878
+ printHeader({
879
+ dir: args.dir,
880
+ id,
881
+ gateLabel,
882
+ files,
883
+ resumed,
884
+ model: modelInfo(provider.config),
885
+ });
886
+
887
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
888
+
889
+ // Ctrl-C: while a turn is running, abort it and return to the prompt; while
890
+ // idle at the prompt, quit. (readline emits SIGINT on the interface, so the
891
+ // process isn't killed — we decide what it means.)
892
+ let active: AbortController | null = null;
893
+ // Lines typed WHILE a run is in flight — drained at each turn boundary to steer
894
+ // the model (see Session.send `steer`), instead of blocking until the run ends.
895
+ const pending: string[] = [];
896
+
897
+ rl.on("SIGINT", () => {
898
+ if (active !== null) {
899
+ active.abort();
900
+ } else {
901
+ rl.close();
902
+ }
903
+ });
904
+
905
+ // Explicit `--web` (no Q&A): the FIRST message is the build, so stage it
906
+ // (plan+types → implement). Cleared after, so follow-ups are plain sends.
907
+ let stagedWebPending = args.web && resumed === null;
908
+ // Plan mode (`--plan` or toggled by /plan). For a staged web build it pauses
909
+ // after the design phase to review the plan; for EVERYTHING else it is the
910
+ // general read-only mode: the agent explores, asks clarifying questions, and
911
+ // proposes a plan — only an explicit approval unlocks tools and implements.
912
+ // A resumed session restores its saved mode (the read-only guarantee must
913
+ // survive `--continue`).
914
+ let planMode = args.plan || (resumed?.planMode ?? false);
915
+ // True once a plan-mode exchange has happened, so a stray "approve" before any
916
+ // discussion is just a message, not an approval.
917
+ let planDiscussed = false;
918
+
919
+ session.setPlanMode(planMode);
920
+
921
+ // While set, the next user line is the plan-review reply ("approve", or edits to
922
+ // fold into phase 2) — the design phase has run and is waiting at the checkpoint.
923
+ let awaitingPlanApproval = false;
924
+
925
+ const configureWeb = async (framework: WebFramework): Promise<void> => {
926
+ process.stdout.write(
927
+ `\n ↳ scaffolding a ${frameworkLabel(framework)} project\n`
928
+ );
929
+ await setUpWebProject(args.dir, framework);
930
+ session.setGate(buildWebGate(framework).command);
931
+ session.setFix(buildWebFix(framework));
932
+ session.setIncrementalCheck(buildWebTscCheck());
933
+ session.guide(webGuidance(framework));
934
+ // A from-scratch web build needs the big turn budget — the default cap was
935
+ // measured to cut a todo app off mid-write, before its gate ever ran.
936
+ session.setMaxTurns(LOOP_LIMITS.webMaxTurns);
937
+ };
938
+
939
+ // The `scaffold_web` tool invokes this when the AGENT decides to build a web app
940
+ // (the framework string is validated tool-side). `configureWeb` closes over the
941
+ // mutable `session`, so this stays correct across `/clear`; re-applied below.
942
+ const setupWeb = (framework: string): Promise<void> =>
943
+ configureWeb(framework === "vanilla" ? "vanilla" : "react");
944
+
945
+ session.setSetupWeb(setupWeb);
946
+
947
+ // Last-turn summary, surfaced in the status line shown before each prompt.
948
+ let lastTurns = 0;
949
+ let lastElapsedMs = 0;
950
+ let lastStatus = "ready";
951
+
952
+ // Run one user-driven exchange: fresh abort controller, time it, record the
953
+ // outcome for the status line, persist. `run` gets the live signal + a steer
954
+ // drain so in-flight user messages reach the model.
955
+ const drive = async (
956
+ run: (opts: { signal: AbortSignal; steer: () => string[] }) => Promise<{
957
+ status: string;
958
+ turns: number;
959
+ }>
960
+ ): Promise<void> => {
961
+ active = new AbortController();
962
+ const started = performance.now();
963
+
964
+ spinner.start();
965
+
966
+ try {
967
+ const result = await run({
968
+ signal: active.signal,
969
+ steer: () => pending.splice(0, pending.length),
970
+ });
971
+
972
+ lastTurns = result.turns;
973
+ lastElapsedMs = performance.now() - started;
974
+ lastStatus = result.status;
975
+ } finally {
976
+ spinner.stop();
977
+ active = null;
978
+ }
979
+
980
+ await persist();
981
+ };
982
+
983
+ const runSend = (line: string): Promise<void> =>
984
+ drive((opts) => session.send(line, opts));
985
+
986
+ // A from-scratch web build: stage it (plan + types, then implement) so the
987
+ // model designs the type contract before writing UI — far less API invention.
988
+ // The design phase gates on TYPES only (tsc + lint) so contract errors surface
989
+ // early and small, not as a final avalanche. `withPlan` is the web flow's OWN
990
+ // checkpoint (design writes types, so general read-only plan mode must be off).
991
+ const runStagedBuild = (
992
+ line: string,
993
+ framework: WebFramework,
994
+ withPlan: boolean
995
+ ): Promise<void> =>
996
+ withPlan
997
+ ? runPlanned(line, framework)
998
+ : drive((opts) =>
999
+ session.buildStaged(line, opts, buildWebTypeGate(framework).command)
1000
+ );
1001
+
1002
+ // Plan mode: run the design phase, then show the model's plan and PAUSE — the
1003
+ // next user line approves it (or edits it, folded into phase 2). The design runs
1004
+ // inside drive() (signal/steer/persist); the quick plan summary is captured for
1005
+ // the prompt that follows.
1006
+ const runPlanned = async (
1007
+ line: string,
1008
+ framework: WebFramework
1009
+ ): Promise<void> => {
1010
+ let plan = "";
1011
+
1012
+ await drive(async (opts) => {
1013
+ const designed = await session.designBuild(
1014
+ line,
1015
+ opts,
1016
+ buildWebTypeGate(framework).command
1017
+ );
1018
+
1019
+ if (designed.status !== "interrupted") {
1020
+ plan = await session.generatePlan();
1021
+ }
1022
+
1023
+ return designed;
1024
+ });
1025
+
1026
+ if (plan.length > 0) {
1027
+ process.stdout.write(
1028
+ `\n📋 PLAN — review, then type 'approve' to build, or describe changes:\n\n${plan}\n\n`
1029
+ );
1030
+ awaitingPlanApproval = true;
1031
+ }
1032
+ };
1033
+
1034
+ const dispatch = async (line: string): Promise<void> => {
1035
+ // A reply to the plan checkpoint: "approve" (build as-planned) or any other
1036
+ // text = corrections folded into the implement phase. Either way phase 2 runs.
1037
+ if (awaitingPlanApproval) {
1038
+ awaitingPlanApproval = false;
1039
+
1040
+ const approved = isApproval(line);
1041
+ const notes = approved ? "" : line;
1042
+
1043
+ if (!approved) {
1044
+ process.stdout.write(" ↳ folding your changes into the build\n");
1045
+ }
1046
+
1047
+ await drive((opts) => session.implementBuild(notes, opts));
1048
+
1049
+ return;
1050
+ }
1051
+
1052
+ // Explicit --web: the first message is a from-scratch build — stage it. The
1053
+ // staged flow has its OWN plan checkpoint (its design phase writes types),
1054
+ // so general read-only plan mode hands over to it here.
1055
+ if (stagedWebPending) {
1056
+ stagedWebPending = false;
1057
+
1058
+ const withPlan = planMode;
1059
+
1060
+ planMode = false;
1061
+ planDiscussed = false;
1062
+ session.setPlanMode(false);
1063
+ await runStagedBuild(line, "react", withPlan);
1064
+
1065
+ return;
1066
+ }
1067
+
1068
+ // GENERAL plan mode, approval: unlock the tools and implement the plan that
1069
+ // is already the latest assistant message. Only an explicit approval word
1070
+ // counts ("yes" may be answering one of the model's clarifying questions).
1071
+ if (planMode && planDiscussed && isPlanApproval(line)) {
1072
+ planMode = false;
1073
+ planDiscussed = false;
1074
+ session.setPlanMode(false);
1075
+ process.stdout.write(" ✓ plan approved — implementing\n");
1076
+ await drive((opts) => session.send(PLAN_APPROVED_NOTE, opts));
1077
+
1078
+ return;
1079
+ }
1080
+
1081
+ // GENERAL plan mode, discussion: the agent explores read-only, asks its
1082
+ // clarifying questions, and proposes/revises a plan. Stays in plan mode.
1083
+ if (planMode) {
1084
+ await runSend(line);
1085
+ planDiscussed = true;
1086
+
1087
+ const last = session.messages.at(-1);
1088
+ const planned =
1089
+ last?.role === "assistant" && /^##\s*plan\b/im.test(last.content);
1090
+
1091
+ process.stdout.write(
1092
+ planned
1093
+ ? "\n 📋 plan ready — reply to refine, or type 'approve' to implement\n"
1094
+ : "\n (plan mode — reply to refine, or type 'approve' to implement)\n"
1095
+ );
1096
+
1097
+ return;
1098
+ }
1099
+
1100
+ // No up-front classifier: the AGENT decides. It calls `scaffold_web` itself
1101
+ // when the request is a from-scratch web app, and just answers/edits otherwise
1102
+ // (so "render a table in the CLI" is no longer mis-scaffolded as a Vite app).
1103
+ await runSend(line);
1104
+ };
1105
+
1106
+ // Slash-command dispatch. Returns true to EXIT the REPL. Kept as a closure so
1107
+ // it can rebuild `session` (e.g. /clear) and reach config/persist.
1108
+ const command = async (line: string): Promise<boolean> => {
1109
+ const [verb, ...rest] = line.slice(1).split(" ");
1110
+ const arg = rest.join(" ").trim();
1111
+
1112
+ switch ((verb ?? "").toLowerCase()) {
1113
+ case "exit":
1114
+ case "quit":
1115
+ return true;
1116
+ case "help":
1117
+ process.stdout.write(`${HELP}\n`);
1118
+ break;
1119
+ case "clear":
1120
+ session = await Session.create(config);
1121
+ session.setSetupWeb(setupWeb);
1122
+ session.setPlanMode(planMode); // a /clear must not silently drop the mode
1123
+ planDiscussed = false;
1124
+ await persist();
1125
+ process.stdout.write("conversation cleared\n");
1126
+ break;
1127
+
1128
+ case "compact": {
1129
+ const { before, after } = await session.compact();
1130
+
1131
+ await persist();
1132
+ process.stdout.write(`compacted ${before} → ${after} messages\n`);
1133
+ break;
1134
+ }
1135
+
1136
+ case "plan":
1137
+ planMode = !planMode;
1138
+ planDiscussed = false;
1139
+ session.setPlanMode(planMode);
1140
+ process.stdout.write(
1141
+ planMode
1142
+ ? "plan mode ON — read-only: the agent explores, asks, and proposes " +
1143
+ "a plan; type 'approve' to implement\n"
1144
+ : "plan mode OFF\n"
1145
+ );
1146
+ break;
1147
+
1148
+ case "gate":
1149
+ session.setGate(arg);
1150
+ process.stdout.write(
1151
+ arg.length > 0 ? `gate: ${arg}\n` : "gate cleared\n"
1152
+ );
1153
+ // Persist immediately so a `/gate` change survives even if the user quits
1154
+ // before the next send (persist otherwise only runs after a turn).
1155
+ await persist();
1156
+ break;
1157
+
1158
+ case "files": {
1159
+ const globs = arg
1160
+ .split(",")
1161
+ .map((s) => s.trim())
1162
+ .filter(Boolean);
1163
+
1164
+ session.setScope(globs.length > 0 ? globs : WHOLE_REPO);
1165
+ process.stdout.write(`scope: ${scopeLabel(session.scope)}\n`);
1166
+ await persist();
1167
+ break;
1168
+ }
1169
+
1170
+ case "model": {
1171
+ const result = await runModelCommand({
1172
+ arg,
1173
+ provider,
1174
+ activeName,
1175
+ fallbackEntry: activeModel.entry,
1176
+ contextWindow,
1177
+ });
1178
+
1179
+ activeName = result.activeName;
1180
+ contextWindow = result.contextWindow;
1181
+ break;
1182
+ }
1183
+
1184
+ case "sessions":
1185
+ await printSessions(args.dir);
1186
+ break;
1187
+
1188
+ case "cost": {
1189
+ const chars = session.messages.reduce(
1190
+ (sum, m) => sum + m.content.length,
1191
+ 0
1192
+ );
1193
+
1194
+ process.stdout.write(
1195
+ ` ${String(session.messages.length)} messages · ~${String(Math.round(chars / 4))} tokens (rough)\n`
1196
+ );
1197
+ break;
1198
+ }
1199
+
1200
+ default:
1201
+ process.stdout.write(`unknown command: ${line} (try /help)\n`);
1202
+ }
1203
+
1204
+ return false;
1205
+ };
1206
+
1207
+ // The persistent status line, shown above every prompt so the model, real
1208
+ // context-window usage, scope, and last-turn outcome are always in view.
1209
+ const prompt = (): void => {
1210
+ process.stdout.write("\n");
1211
+ process.stdout.write(
1212
+ renderStatus({
1213
+ model: modelInfo(provider.config).model,
1214
+ contextTokens: session.contextTokens,
1215
+ contextWindow,
1216
+ turns: lastTurns,
1217
+ elapsedMs: lastElapsedMs,
1218
+ status: lastStatus,
1219
+ scope: scopeLabel(session.scope) + (planMode ? " · PLAN" : ""),
1220
+ })
1221
+ );
1222
+ process.stdout.write("› ");
1223
+ };
1224
+
1225
+ await new Promise<void>((resolveLoop) => {
1226
+ let busy = false;
1227
+ let closed = false;
1228
+
1229
+ // Finish the loop only when stdin has closed AND no run is in flight — so a
1230
+ // stdin EOF (piped input / Ctrl-D) never kills a build mid-turn.
1231
+ const maybeFinish = (): void => {
1232
+ if (closed && !busy) {
1233
+ resolveLoop();
1234
+ }
1235
+ };
1236
+
1237
+ // Handle one idle line (slash command or a message), then any queued follow-up.
1238
+ const runLine = async (line: string): Promise<void> => {
1239
+ busy = true;
1240
+
1241
+ try {
1242
+ if (line.startsWith("/")) {
1243
+ if (await command(line)) {
1244
+ rl.close();
1245
+
1246
+ return;
1247
+ }
1248
+ } else {
1249
+ await dispatch(line);
1250
+ }
1251
+ } finally {
1252
+ busy = false;
1253
+ }
1254
+
1255
+ // A line typed in the gap after the last steer-drain becomes the next turn.
1256
+ const next = pending.shift();
1257
+
1258
+ if (next !== undefined) {
1259
+ void runLine(next);
1260
+
1261
+ return;
1262
+ }
1263
+
1264
+ if (closed) {
1265
+ maybeFinish();
1266
+ } else {
1267
+ prompt();
1268
+ }
1269
+ };
1270
+
1271
+ // Event-driven (not for-await) so stdin is read DURING a run: a line typed
1272
+ // mid-run is queued to steer the next turn (or, if "/exit", aborts). This is
1273
+ // what makes it feel like a real harness — you can redirect without waiting.
1274
+ rl.on("line", (raw) => {
1275
+ const line = raw.trim();
1276
+
1277
+ if (line.length === 0) {
1278
+ if (!busy) {
1279
+ prompt();
1280
+ }
1281
+
1282
+ return;
1283
+ }
1284
+
1285
+ if (busy) {
1286
+ if (line === "/exit" || line === "/quit") {
1287
+ active?.abort();
1288
+ rl.close();
1289
+ } else {
1290
+ pending.push(line);
1291
+ process.stdout.write(" ↳ queued (steers the next turn)\n");
1292
+ }
1293
+
1294
+ return;
1295
+ }
1296
+
1297
+ void runLine(line);
1298
+ });
1299
+
1300
+ rl.on("close", () => {
1301
+ closed = true;
1302
+ maybeFinish();
1303
+ });
1304
+
1305
+ if (args.task.length > 0) {
1306
+ void runLine(args.task); // sent as the first message; prompts when done
1307
+ } else {
1308
+ prompt();
1309
+ }
1310
+ });
1311
+
1312
+ return 0;
1313
+ }
1314
+
1315
+ async function main(): Promise<number> {
1316
+ const args = parseArgs(process.argv.slice(2));
1317
+
1318
+ // A positional task with a scope + gate ⇒ one-shot; otherwise interactive.
1319
+ return isOneShot(args) ? runOnce(args) : repl(args);
1320
+ }
1321
+
1322
+ if (import.meta.main) {
1323
+ main()
1324
+ .then((code) => {
1325
+ process.exit(code);
1326
+ })
1327
+ .catch((err: unknown) => {
1328
+ process.stderr.write(
1329
+ `tsforge: ${err instanceof Error ? err.message : String(err)}\n`
1330
+ );
1331
+ process.exit(1);
1332
+ });
1333
+ }