@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.
- package/bin/tsforge.js +2 -0
- package/package.json +35 -0
- package/src/agent/agent.constants.ts +382 -0
- package/src/agent/agent.types.ts +34 -0
- package/src/agent/index.ts +4 -0
- package/src/agent/model-agent.ts +297 -0
- package/src/agent/tool-repair.ts +194 -0
- package/src/agent/tools.ts +190 -0
- package/src/browser/checks.ts +96 -0
- package/src/browser/index.ts +8 -0
- package/src/browser/oracle.ts +303 -0
- package/src/classify.ts +48 -0
- package/src/cli.ts +1333 -0
- package/src/config/config.constants.ts +9 -0
- package/src/config/flags.ts +32 -0
- package/src/config/index.ts +8 -0
- package/src/config/tsforge-config.ts +301 -0
- package/src/constitution/baseline.ts +257 -0
- package/src/detect-gate.ts +498 -0
- package/src/eval/eval.types.ts +36 -0
- package/src/eval/index.ts +3 -0
- package/src/eval/judge.ts +62 -0
- package/src/eval/score.ts +39 -0
- package/src/files/create.ts +22 -0
- package/src/files/edit.ts +193 -0
- package/src/files/files.constants.ts +11 -0
- package/src/files/files.types.ts +81 -0
- package/src/files/hashline-format.ts +110 -0
- package/src/files/hashline.ts +689 -0
- package/src/files/index.ts +19 -0
- package/src/index.ts +8 -0
- package/src/inference/index.ts +6 -0
- package/src/inference/inference.constants.ts +34 -0
- package/src/inference/inference.types.ts +123 -0
- package/src/inference/openai-compatible.ts +113 -0
- package/src/inference/stream-guard.ts +161 -0
- package/src/inference/stream.ts +370 -0
- package/src/inference/transport.ts +78 -0
- package/src/inference/wire.ts +0 -0
- package/src/lib/fs/fs.ts +126 -0
- package/src/lib/fs/fs.types.ts +5 -0
- package/src/lib/fs/index.ts +3 -0
- package/src/lib/fs/process.ts +146 -0
- package/src/lib/guards/guards.ts +9 -0
- package/src/lib/guards/index.ts +1 -0
- package/src/lib/json/index.ts +1 -0
- package/src/lib/json/json.ts +12 -0
- package/src/lib/scope/index.ts +2 -0
- package/src/lib/scope/scope.constants.ts +3 -0
- package/src/lib/scope/scope.ts +40 -0
- package/src/loop/astgrep-fix.ts +228 -0
- package/src/loop/feedback/feedback.ts +138 -0
- package/src/loop/feedback/index.ts +8 -0
- package/src/loop/feedback/meta-rule-docs.ts +41 -0
- package/src/loop/feedback/meta-rule-feedback.ts +61 -0
- package/src/loop/feedback/rule-docs.generated.json +112 -0
- package/src/loop/feedback/rule-docs.ts +342 -0
- package/src/loop/index.ts +19 -0
- package/src/loop/loop.constants.ts +68 -0
- package/src/loop/loop.types.ts +99 -0
- package/src/loop/prompt/index.ts +2 -0
- package/src/loop/prompt/project-map.ts +69 -0
- package/src/loop/prompt/prompt.ts +107 -0
- package/src/loop/quality.ts +174 -0
- package/src/loop/rule-docs.generated.json +367 -0
- package/src/loop/run-spec.ts +88 -0
- package/src/loop/run.ts +400 -0
- package/src/loop/session.ts +1410 -0
- package/src/loop/tools/add-dependency.ts +71 -0
- package/src/loop/tools/condense.ts +498 -0
- package/src/loop/tools/edit-hashline.ts +80 -0
- package/src/loop/tools/execute-tool.ts +80 -0
- package/src/loop/tools/file-ops.ts +323 -0
- package/src/loop/tools/index.ts +2 -0
- package/src/loop/tools/lsp-ops.ts +222 -0
- package/src/loop/tools/scaffold-routes.ts +68 -0
- package/src/loop/tools/scaffold-ui.ts +62 -0
- package/src/loop/tools/scaffold-web.ts +35 -0
- package/src/loop/tools/tool-context.ts +126 -0
- package/src/loop/ttsr-defaults.ts +53 -0
- package/src/loop/ttsr.ts +322 -0
- package/src/loop/turn.ts +856 -0
- package/src/lsp/index.ts +2 -0
- package/src/lsp/lsp.types.ts +56 -0
- package/src/lsp/service.ts +500 -0
- package/src/meta-rules/context.ts +195 -0
- package/src/meta-rules/index.ts +9 -0
- package/src/meta-rules/meta-rules.types.ts +47 -0
- package/src/meta-rules/parsers/package-json-parser.ts +51 -0
- package/src/meta-rules/registry.ts +37 -0
- package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
- package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
- package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
- package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
- package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
- package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
- package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
- package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
- package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
- package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
- package/src/meta-rules/runner.ts +64 -0
- package/src/models-config.ts +196 -0
- package/src/render/ansi.ts +289 -0
- package/src/render/banner.ts +113 -0
- package/src/render/box.ts +134 -0
- package/src/render/index.ts +7 -0
- package/src/render/markdown.ts +123 -0
- package/src/render/render.types.ts +21 -0
- package/src/render/stream-markdown.ts +128 -0
- package/src/render/style.ts +26 -0
- package/src/rule-packs/bullmq/index.ts +39 -0
- package/src/rule-packs/bullmq/rules/index.ts +7 -0
- package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
- package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
- package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
- package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
- package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
- package/src/rule-packs/bullmq/utils.ts +334 -0
- package/src/rule-packs/code-flow/index.ts +25 -0
- package/src/rule-packs/code-flow/rules/index.ts +3 -0
- package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
- package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
- package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
- package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
- package/src/rule-packs/comment-hygiene/index.ts +25 -0
- package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
- package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
- package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
- package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
- package/src/rule-packs/create-rule.ts +9 -0
- package/src/rule-packs/drizzle/index.ts +41 -0
- package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
- package/src/rule-packs/drizzle/rules/index.ts +8 -0
- package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
- package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
- package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
- package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
- package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
- package/src/rule-packs/drizzle/utils.ts +115 -0
- package/src/rule-packs/elysia/index.ts +43 -0
- package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
- package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
- package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
- package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
- package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
- package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
- package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
- package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
- package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
- package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
- package/src/rule-packs/env-access/index.ts +23 -0
- package/src/rule-packs/env-access/rules/index.ts +2 -0
- package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
- package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
- package/src/rule-packs/i18n-keys/index.ts +19 -0
- package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
- package/src/rule-packs/index.ts +139 -0
- package/src/rule-packs/jwt-cookies/index.ts +25 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
- package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
- package/src/rule-packs/jwt-cookies/utils.ts +188 -0
- package/src/rule-packs/oauth-security/index.ts +25 -0
- package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
- package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
- package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
- package/src/rule-packs/oauth-security/utils.ts +127 -0
- package/src/rule-packs/react-component-architecture/index.ts +35 -0
- package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
- package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
- package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
- package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
- package/src/rule-packs/react-component-architecture/utils.ts +47 -0
- package/src/rule-packs/rule-packs.types.ts +18 -0
- package/src/rule-packs/structured-logging/index.ts +26 -0
- package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
- package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
- package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
- package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
- package/src/rule-packs/tanstack-query/index.ts +20 -0
- package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
- package/src/rule-packs/test-conventions/index.ts +23 -0
- package/src/rule-packs/test-conventions/rules/index.ts +2 -0
- package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
- package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
- package/src/rule-packs/utils.ts +142 -0
- package/src/session-store.ts +359 -0
- package/src/spec/generate-tests.ts +213 -0
- package/src/spec/index.ts +5 -0
- package/src/spec/parse.ts +152 -0
- package/src/spec/review-tests.ts +162 -0
- package/src/spec/spec.constants.ts +13 -0
- package/src/spec/spec.types.ts +79 -0
- package/src/stack-detection/detect.ts +246 -0
- package/src/stack-detection/index.ts +3 -0
- package/src/stack-detection/packs.ts +174 -0
- package/src/stack-detection/stack-detection.types.ts +47 -0
- package/src/validate/accept.ts +49 -0
- package/src/validate/errors.ts +35 -0
- package/src/validate/index.ts +12 -0
- package/src/validate/parse.ts +148 -0
- package/src/validate/run-tests.ts +59 -0
- package/src/validate/validate.ts +40 -0
- package/src/validate/validate.types.ts +52 -0
- package/src/web-components.ts +638 -0
- package/src/web-coverage.ts +89 -0
- package/src/web-routes.ts +151 -0
- package/src/web-templates.ts +1011 -0
- package/strict.eslint.config.mjs +84 -0
- 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
|
+
}
|