@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
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import type { IAgent, IAgentContext } from "./agent.types";
|
|
3
|
+
import type { IChatMessage, IProvider } from "../inference";
|
|
4
|
+
import type { ErrorSet } from "../validate";
|
|
5
|
+
import { applyEdits } from "../files/edit";
|
|
6
|
+
import { applyCreate } from "../files/create";
|
|
7
|
+
import { EDIT_FAIL_REASON } from "../files";
|
|
8
|
+
import { isInScope, normalizeWorkspacePath } from "../lib/scope";
|
|
9
|
+
import { readFiles, type IFileView } from "../lib/fs";
|
|
10
|
+
import { EDIT_TOOL, CREATE_TOOL, TOOL_NAME } from "./agent.constants";
|
|
11
|
+
import { toEdits, toCreate } from "./tools";
|
|
12
|
+
import { ruleHelp } from "../loop/feedback";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The errors the agent can actually act on: those in its editable files (plus
|
|
16
|
+
* file-less generic ones). Cascade errors located in read-only context (e.g. a
|
|
17
|
+
* test file failing because the not-yet-created impl can't be imported) are NOT
|
|
18
|
+
* the model's to fix — surfacing them makes it spiral on a contradiction it
|
|
19
|
+
* can't resolve. Those clear on their own once the editable files are correct.
|
|
20
|
+
*/
|
|
21
|
+
function editableErrors(errors: ErrorSet, files: string[]): ErrorSet {
|
|
22
|
+
return errors.filter((e) => {
|
|
23
|
+
if (e.file === undefined) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return isInScope(e.file, files) || isInScope(basename(e.file), files);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The real agent: show the model the declared files + current failures, ask for
|
|
33
|
+
* `edit`/`create` tool calls, and apply them through the edit engine (which
|
|
34
|
+
* enforces exact/unique matches and no-clobber creates). The model can only
|
|
35
|
+
* produce a matching `oldString` if it sees the file, so we read the declared
|
|
36
|
+
* files into context first.
|
|
37
|
+
*/
|
|
38
|
+
export function modelAgent(
|
|
39
|
+
provider: IProvider,
|
|
40
|
+
options: { temperature?: number; thinkingTokenBudget?: number } = {}
|
|
41
|
+
): IAgent {
|
|
42
|
+
// Edit-application failures from the previous cycle, fed back so the model
|
|
43
|
+
// learns when an edit was rejected (otherwise rejections are invisible to it).
|
|
44
|
+
let lastFailures: string[] = [];
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
async implement(ctx: IAgentContext): Promise<void> {
|
|
48
|
+
const editable = await readFiles(ctx.cwd, ctx.task.files);
|
|
49
|
+
const readonly = await readFiles(ctx.cwd, ctx.task.context ?? []);
|
|
50
|
+
const res = await provider.complete(
|
|
51
|
+
buildMessages(ctx, editable, readonly, lastFailures),
|
|
52
|
+
{
|
|
53
|
+
tools: [EDIT_TOOL, CREATE_TOOL],
|
|
54
|
+
toolChoice: "auto",
|
|
55
|
+
// Temp 0 by default — the eval sweep showed it's decisively better for
|
|
56
|
+
// convergence on coding tasks (temp 0: 100% pass vs temp 0.5: 0%).
|
|
57
|
+
temperature: options.temperature ?? 0,
|
|
58
|
+
// Cap reasoning so the quality-repair pass can't ramble unbounded
|
|
59
|
+
// ("writing novels"): the budget that bounds the implement loop must
|
|
60
|
+
// bound this agent too, or the cap leaks.
|
|
61
|
+
...(options.thinkingTokenBudget === undefined
|
|
62
|
+
? {}
|
|
63
|
+
: { thinkingTokenBudget: options.thinkingTokenBudget }),
|
|
64
|
+
// Stream: keeps the connection alive on long thinking calls (no
|
|
65
|
+
// idle-drop) and emits a heartbeat. Reasoning is NOT logged — only a
|
|
66
|
+
// dim dot per ~200 reasoning chars (proof of life, not the wall).
|
|
67
|
+
onToken: (text) => {
|
|
68
|
+
ctx.report?.({ kind: "token", task: ctx.task.id, message: text });
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const failures: string[] = [];
|
|
74
|
+
|
|
75
|
+
for (const call of res.toolCalls) {
|
|
76
|
+
const failure = await applyCall(ctx, call.name, call.arguments);
|
|
77
|
+
|
|
78
|
+
if (failure !== null) {
|
|
79
|
+
failures.push(failure);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
lastFailures = failures;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Apply one tool call; return a human-readable failure message, or null on success. */
|
|
89
|
+
async function applyCall(
|
|
90
|
+
ctx: IAgentContext,
|
|
91
|
+
name: string,
|
|
92
|
+
args: Record<string, unknown>
|
|
93
|
+
): Promise<string | null> {
|
|
94
|
+
if (name === TOOL_NAME.edit) {
|
|
95
|
+
return applyAgentEdit(ctx, args);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (name === TOOL_NAME.create) {
|
|
99
|
+
return applyAgentCreate(ctx, args);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function applyAgentEdit(
|
|
106
|
+
ctx: IAgentContext,
|
|
107
|
+
args: Record<string, unknown>
|
|
108
|
+
): Promise<string | null> {
|
|
109
|
+
const edit = toEdits(args);
|
|
110
|
+
|
|
111
|
+
if (edit === null) {
|
|
112
|
+
return "an `edit` call had malformed arguments (need `file` plus `oldString`/`newString` or an `edits` array)";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
edit.file = normalizeWorkspacePath(ctx.cwd, edit.file);
|
|
116
|
+
|
|
117
|
+
const scopeError = outOfScope(ctx, edit.file);
|
|
118
|
+
|
|
119
|
+
if (scopeError !== null) {
|
|
120
|
+
return scopeError;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const result = await applyEdits(ctx.cwd, edit.file, edit.edits);
|
|
124
|
+
|
|
125
|
+
if (result.ok) {
|
|
126
|
+
for (const r of edit.edits) {
|
|
127
|
+
ctx.report?.({
|
|
128
|
+
kind: "edit",
|
|
129
|
+
task: ctx.task.id,
|
|
130
|
+
file: edit.file,
|
|
131
|
+
message: `edit ${edit.file}`,
|
|
132
|
+
oldString: r.oldString,
|
|
133
|
+
newString: r.newString,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const where =
|
|
141
|
+
edit.edits.length > 1 ? ` (replacement #${result.index + 1})` : "";
|
|
142
|
+
const detail =
|
|
143
|
+
result.reason === EDIT_FAIL_REASON.ambiguous
|
|
144
|
+
? `oldString matched ${result.matches ?? 0} places — include more surrounding lines to make it unique`
|
|
145
|
+
: result.reason;
|
|
146
|
+
|
|
147
|
+
ctx.report?.({
|
|
148
|
+
kind: "edit",
|
|
149
|
+
task: ctx.task.id,
|
|
150
|
+
file: edit.file,
|
|
151
|
+
message: `${edit.file} — rejected (${result.reason})`,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return `edit to ${edit.file} REJECTED${where}: ${detail}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function applyAgentCreate(
|
|
158
|
+
ctx: IAgentContext,
|
|
159
|
+
args: Record<string, unknown>
|
|
160
|
+
): Promise<string | null> {
|
|
161
|
+
const create = toCreate(args);
|
|
162
|
+
|
|
163
|
+
if (create === null) {
|
|
164
|
+
return "a `create` call had malformed arguments (need file, content)";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
create.file = normalizeWorkspacePath(ctx.cwd, create.file);
|
|
168
|
+
|
|
169
|
+
const scopeError = outOfScope(ctx, create.file);
|
|
170
|
+
|
|
171
|
+
if (scopeError !== null) {
|
|
172
|
+
return scopeError;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const result = await applyCreate(ctx.cwd, create);
|
|
176
|
+
|
|
177
|
+
if (result.ok) {
|
|
178
|
+
ctx.report?.({
|
|
179
|
+
kind: "create",
|
|
180
|
+
task: ctx.task.id,
|
|
181
|
+
file: create.file,
|
|
182
|
+
message: `create ${create.file}`,
|
|
183
|
+
content: create.content,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
ctx.report?.({
|
|
190
|
+
kind: "create",
|
|
191
|
+
task: ctx.task.id,
|
|
192
|
+
file: create.file,
|
|
193
|
+
message: `${create.file} — rejected (already exists)`,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return `create ${create.file} REJECTED: file already exists — use \`edit\` instead`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Reject (and report) an edit/create whose path is outside the editable scope. */
|
|
200
|
+
function outOfScope(ctx: IAgentContext, file: string): string | null {
|
|
201
|
+
if (isInScope(file, ctx.task.files)) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
ctx.report?.({
|
|
206
|
+
kind: "edit",
|
|
207
|
+
task: ctx.task.id,
|
|
208
|
+
file,
|
|
209
|
+
message: `${file} — out of scope (read-only)`,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
return `${file} is OUT OF SCOPE — you may only edit/create: ${ctx.task.files.join(
|
|
213
|
+
", "
|
|
214
|
+
)}. Read-only context (tests, etc.) must not be changed.`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildMessages(
|
|
218
|
+
ctx: IAgentContext,
|
|
219
|
+
editable: IFileView[],
|
|
220
|
+
readonly: IFileView[],
|
|
221
|
+
priorFailures: string[]
|
|
222
|
+
): IChatMessage[] {
|
|
223
|
+
const system = [
|
|
224
|
+
"You are a TypeScript engineer working inside an automated harness. Make the task pass by emitting `edit` and `create` tool calls.",
|
|
225
|
+
"`edit` replaces an exact, UNIQUE snippet in an existing file — include enough surrounding lines so oldString matches exactly once. `create` makes a new file.",
|
|
226
|
+
"Scope: you may ONLY edit/create the editable files. NEVER edit the read-only context (tests, specs) — it is fixed.",
|
|
227
|
+
"House rules (enforced by the gate): interfaces are `I`-prefixed and you update EVERY reference when you rename a type; never use the non-null assertion `!` — guard index access instead (`const x = arr[i]; if (x !== undefined)`); avoid `while (true)` and other constant conditions — prefer `.filter(...)`, `for...of`, or a bounded loop; no `any` and no `as`; change only what the failures require and don't re-break fixed issues.",
|
|
228
|
+
].join("\n");
|
|
229
|
+
|
|
230
|
+
const editableText =
|
|
231
|
+
editable.length > 0
|
|
232
|
+
? editable.map((f) => `File ${f.path}:\n${f.content}`).join("\n\n")
|
|
233
|
+
: "(no editable files exist yet — create them)";
|
|
234
|
+
|
|
235
|
+
const contextText =
|
|
236
|
+
readonly.length > 0
|
|
237
|
+
? `Read-only context (do NOT edit):\n${readonly
|
|
238
|
+
.map((f) => `File ${f.path}:\n${f.content}`)
|
|
239
|
+
.join("\n\n")}`
|
|
240
|
+
: "";
|
|
241
|
+
|
|
242
|
+
// Only show failures the model can actually fix (in its editable files).
|
|
243
|
+
const ownErrors = editableErrors(ctx.errors, ctx.task.files);
|
|
244
|
+
const readOnlyCount = ctx.errors.length - ownErrors.length;
|
|
245
|
+
|
|
246
|
+
const failures =
|
|
247
|
+
ownErrors.length > 0
|
|
248
|
+
? `Current failures in your editable files:\n${ownErrors
|
|
249
|
+
.map((e) => `- ${e.message}`)
|
|
250
|
+
.join("\n")}`
|
|
251
|
+
: "No failures in your editable files.";
|
|
252
|
+
|
|
253
|
+
const readOnlyNote =
|
|
254
|
+
readOnlyCount > 0
|
|
255
|
+
? `Note: ${readOnlyCount} other gate error(s) are in READ-ONLY files (e.g. tests). They are NOT yours to fix and will resolve once your editable files export the right shapes — ignore them.`
|
|
256
|
+
: "";
|
|
257
|
+
|
|
258
|
+
// The failing rules' own docs (bad→good), keyed to the model's OWN errors —
|
|
259
|
+
// so it fixes from the rule's example instead of re-deriving it.
|
|
260
|
+
const coaching = ruleHelp(ownErrors);
|
|
261
|
+
const guidance =
|
|
262
|
+
coaching.length > 0
|
|
263
|
+
? `How to satisfy the gate here (the failing rules, with examples):\n${coaching}`
|
|
264
|
+
: "";
|
|
265
|
+
|
|
266
|
+
const rejected =
|
|
267
|
+
priorFailures.length > 0
|
|
268
|
+
? `Your previous edits were REJECTED and did NOT apply — fix and retry:\n${priorFailures
|
|
269
|
+
.map((f) => `- ${f}`)
|
|
270
|
+
.join("\n")}`
|
|
271
|
+
: "";
|
|
272
|
+
|
|
273
|
+
const intent =
|
|
274
|
+
ctx.task.intent !== undefined && ctx.task.intent.length > 0
|
|
275
|
+
? `Spec contract — implement EXACTLY this (authoritative; the tests check it but this states the intent):\n${ctx.task.intent}`
|
|
276
|
+
: "";
|
|
277
|
+
|
|
278
|
+
const user = [
|
|
279
|
+
`Task ${ctx.task.id} (cycle ${ctx.cycle}).`,
|
|
280
|
+
intent,
|
|
281
|
+
`Acceptance command: ${ctx.task.accept}`,
|
|
282
|
+
`Editable files: ${ctx.task.files.join(", ")}`,
|
|
283
|
+
`Editable file contents:\n${editableText}`,
|
|
284
|
+
contextText,
|
|
285
|
+
failures,
|
|
286
|
+
readOnlyNote,
|
|
287
|
+
guidance,
|
|
288
|
+
rejected,
|
|
289
|
+
]
|
|
290
|
+
.filter((s) => s.length > 0)
|
|
291
|
+
.join("\n\n");
|
|
292
|
+
|
|
293
|
+
return [
|
|
294
|
+
{ role: "system", content: system },
|
|
295
|
+
{ role: "user", content: user },
|
|
296
|
+
];
|
|
297
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cost-ordered tool-call repair ladder for open-model reliability.
|
|
3
|
+
*
|
|
4
|
+
* The harness mediates between the model's output distribution and strict tool
|
|
5
|
+
* schemas — rejecting RECOVERABLE noise that commercial models absorb invisibly.
|
|
6
|
+
* As tsforge grows, this is the one place that keeps every new tool forgiving
|
|
7
|
+
* the same way (CommandCode: "open model bad at tool calling is a harness
|
|
8
|
+
* problem" — failure modes are a small finite set).
|
|
9
|
+
*
|
|
10
|
+
* CRITICAL ordering: VALIDATE THEN REPAIR, never preprocess-then-validate. We
|
|
11
|
+
* only touch input the tool's own parser has ALREADY rejected — so a valid
|
|
12
|
+
* input (e.g. file `content` that happens to be JSON-shaped) is never rewritten.
|
|
13
|
+
* The parser is the prior; we spend repair budget only where it disagreed.
|
|
14
|
+
*
|
|
15
|
+
* The ladder (cost-ordered, earliest-win):
|
|
16
|
+
*
|
|
17
|
+
* L0: null-drop, autolink-unwrap — lossless, always safe. Applied first.
|
|
18
|
+
*
|
|
19
|
+
* L1: schema coercion — against the tool's declared params:
|
|
20
|
+
* - stringified arrays: '["a"]' → ["a"] (when param type is array)
|
|
21
|
+
* - stringified numbers/booleans: "42"→42, "true"→true (when typed)
|
|
22
|
+
* - single string → [string] for array params
|
|
23
|
+
* - trim markdown fences from paths (```path``` → path)
|
|
24
|
+
*
|
|
25
|
+
* L2: safe defaults — ONLY where unambiguous (rare; study per-tool failures):
|
|
26
|
+
* - bool params default to true (unusual, but seen when model guesses)
|
|
27
|
+
* - (expand as telemetry surfaces safe patterns; bias toward L3 re-ask)
|
|
28
|
+
*
|
|
29
|
+
* L3: re-ask — when not recoverable:
|
|
30
|
+
* - `recoverable: false` + targeted `feedback` (field, type, example)
|
|
31
|
+
* - turn.ts routes through as the tool result; loop re-asks the model
|
|
32
|
+
* - analyze-malformed.ts histograms re-ask rate per rule
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export interface IRepairResult {
|
|
36
|
+
readonly args: Record<string, unknown>;
|
|
37
|
+
readonly applied: readonly string[]; // e.g. ["drop-null:limit", "coerce:files"]
|
|
38
|
+
readonly recoverable: boolean; // false → re-ask via feedback
|
|
39
|
+
readonly feedback?: string; // targeted error text for the re-ask path
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run the repair ladder on args that failed schema validation. Returns
|
|
44
|
+
* the repaired args, a list of applied repair names (for telemetry), whether
|
|
45
|
+
* the result is usable, and (if not) targeted feedback for the model.
|
|
46
|
+
* Applies repairs in order: L0 (lossless) → L1 (schema coerce) → L2 (defaults)
|
|
47
|
+
* → L3 (feedback if still broken).
|
|
48
|
+
*/
|
|
49
|
+
export function repairArgs(args: Record<string, unknown>): IRepairResult {
|
|
50
|
+
const applied: string[] = [];
|
|
51
|
+
let out = args;
|
|
52
|
+
|
|
53
|
+
// L0: Lossless repairs (null-drop, autolink-unwrap).
|
|
54
|
+
out = applyL0(out, applied);
|
|
55
|
+
|
|
56
|
+
// L1: Schema coercion (stringified arrays, numbers, booleans, markdown fences).
|
|
57
|
+
// For now, we have no schema context here — deferred to handlers that know
|
|
58
|
+
// their field types (toEdits, toCreate, etc.). They COULD call schema-aware
|
|
59
|
+
// coercers; for now they just pass valid structures or null (triggering L3).
|
|
60
|
+
// Future: wire tool schemas and coerce here.
|
|
61
|
+
|
|
62
|
+
// L2: Safe defaults — none established yet; defer to telemetry.
|
|
63
|
+
|
|
64
|
+
// L3: If still broken, handlers will validate and route through L3 feedback.
|
|
65
|
+
// This function returns what we could fix.
|
|
66
|
+
return {
|
|
67
|
+
args: out,
|
|
68
|
+
applied,
|
|
69
|
+
recoverable: true,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* L0: Lossless repairs. Safe on any arg, any type. Mutates `applied` in place.
|
|
75
|
+
*/
|
|
76
|
+
function applyL0(
|
|
77
|
+
args: Record<string, unknown>,
|
|
78
|
+
applied: string[]
|
|
79
|
+
): Record<string, unknown> {
|
|
80
|
+
const out: Record<string, unknown> = {};
|
|
81
|
+
|
|
82
|
+
for (const [key, value] of Object.entries(args)) {
|
|
83
|
+
if (value === null || value === undefined) {
|
|
84
|
+
applied.push(`drop-null:${key}`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (typeof value === "string") {
|
|
89
|
+
const unlinked = unwrapAutoLink(value);
|
|
90
|
+
|
|
91
|
+
if (unlinked !== value) {
|
|
92
|
+
applied.push(`unwrap-autolink:${key}`);
|
|
93
|
+
out[key] = unlinked;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
out[key] = value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const AUTO_LINK = /^\[([^\]]+)\]\(([^)]+)\)$/;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `[notes.md](notes.md)` / `[x](http://x)` where the link text equals the URL
|
|
108
|
+
* (ignoring an `http(s)://` prefix and whitespace) → the text. Anything else
|
|
109
|
+
* (a real link with distinct text/url) is returned unchanged.
|
|
110
|
+
*/
|
|
111
|
+
function unwrapAutoLink(value: string): string {
|
|
112
|
+
const match = AUTO_LINK.exec(value.trim());
|
|
113
|
+
|
|
114
|
+
if (match === null) {
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const text = (match[1] ?? "").replace(/\s+/g, "");
|
|
119
|
+
const url = (match[2] ?? "").replace(/^https?:\/\//, "").replace(/\s+/g, "");
|
|
120
|
+
|
|
121
|
+
return text === url ? (match[1] ?? "").trim() : value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* L1: Schema coercion — per-tool rules applied by handlers that know their
|
|
126
|
+
* field types (via tool schema context). These helpers are called by toEdits,
|
|
127
|
+
* toCreate, toRead, toRun when they detect a coercible mismatch.
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/** Coerce a string to an array if it's a stringified JSON array. */
|
|
131
|
+
export function coerceStringToArray(value: unknown): unknown[] | null {
|
|
132
|
+
if (typeof value !== "string") {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const trimmed = value.trim();
|
|
137
|
+
|
|
138
|
+
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
144
|
+
|
|
145
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Coerce a string to its typed value if it's stringified. */
|
|
152
|
+
export function coerceStringValue(
|
|
153
|
+
value: unknown,
|
|
154
|
+
expectedType: "number" | "boolean"
|
|
155
|
+
): number | boolean | null {
|
|
156
|
+
if (typeof value !== "string") {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const trimmed = value.trim().toLowerCase();
|
|
161
|
+
|
|
162
|
+
if (expectedType === "number") {
|
|
163
|
+
const n = Number(trimmed);
|
|
164
|
+
|
|
165
|
+
return Number.isFinite(n) ? n : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// expectedType === "boolean"
|
|
169
|
+
if (trimmed === "true") {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (trimmed === "false") {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Strip markdown code fences and extra whitespace from a string. */
|
|
181
|
+
export function trimMarkdownFences(value: unknown): string | null {
|
|
182
|
+
if (typeof value !== "string") {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let s = value.trim();
|
|
187
|
+
|
|
188
|
+
// ` ```path.ts``` ` → `path.ts`
|
|
189
|
+
if (s.startsWith("```") && s.endsWith("```")) {
|
|
190
|
+
s = s.slice(3, -3).trim();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return s;
|
|
194
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { ICreateFile, IReplacement } from "../files";
|
|
2
|
+
import { isArray, isRecord } from "../lib/guards";
|
|
3
|
+
import { runShellCommand } from "../lib/fs";
|
|
4
|
+
import { coerceStringToArray, trimMarkdownFences } from "./tool-repair";
|
|
5
|
+
import type { IShellResult } from "./agent.types";
|
|
6
|
+
|
|
7
|
+
/** Default kill-timeout for the `run` tool (ms) — generous enough for a slow
|
|
8
|
+
* test/build, short enough that a hung `tail -f`/`vite dev` can't wedge the
|
|
9
|
+
* harness forever. Override per-process with TSFORGE_RUN_TIMEOUT_MS (0 = off). */
|
|
10
|
+
const DEFAULT_RUN_TIMEOUT_MS = 120_000;
|
|
11
|
+
|
|
12
|
+
function runToolTimeoutMs(): number {
|
|
13
|
+
const env = Number(process.env.TSFORGE_RUN_TIMEOUT_MS);
|
|
14
|
+
|
|
15
|
+
return Number.isFinite(env) && env >= 0 ? env : DEFAULT_RUN_TIMEOUT_MS;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the target file path from a tool call. Our schema asks for `file`, but
|
|
20
|
+
* the model frequently reaches for `path` (the Claude-Code convention) and other
|
|
21
|
+
* synonyms. A schema mismatch on the very FIRST tool call poisons the whole
|
|
22
|
+
* trajectory — observed on react-board: 7 rejected reads in turn 1 sent the
|
|
23
|
+
* model into an inert "let me read…" loop. So we accept the aliases instead of
|
|
24
|
+
* rejecting (input-repair: meet the model where it is). `file` wins if present.
|
|
25
|
+
* Also applies L1 coercions: trims markdown fences, coerces if stringified.
|
|
26
|
+
*/
|
|
27
|
+
export function fileArg(args: Record<string, unknown>): string | null {
|
|
28
|
+
for (const key of ["file", "path", "filename", "filepath", "filePath"]) {
|
|
29
|
+
const value = args[key];
|
|
30
|
+
|
|
31
|
+
if (value === undefined || value === null) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// L1: trim markdown fences (```path.ts``` → path.ts)
|
|
36
|
+
const trimmed = trimMarkdownFences(value);
|
|
37
|
+
|
|
38
|
+
if (trimmed !== null && trimmed.length > 0) {
|
|
39
|
+
return trimmed;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Already a string but not markdown-wrapped.
|
|
43
|
+
if (typeof value === "string" && value.length > 0) {
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Normalize either edit form into a file + ordered replacement list. Accepts the
|
|
53
|
+
* single `{file, oldString, newString}` shape AND the batched `{file, edits:[…]}`
|
|
54
|
+
* shape, so old callers and the multi-site path share one contract.
|
|
55
|
+
* Applies L1 coercions: stringified arrays are parsed.
|
|
56
|
+
*/
|
|
57
|
+
export function toEdits(
|
|
58
|
+
args: Record<string, unknown>
|
|
59
|
+
): { file: string; edits: IReplacement[] } | null {
|
|
60
|
+
const { edits: editsDef, oldString: oldStr, newString: newStr } = args;
|
|
61
|
+
let edits = editsDef;
|
|
62
|
+
const file = fileArg(args);
|
|
63
|
+
|
|
64
|
+
if (file === null) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// L1: Coerce stringified arrays (e.g. from models that emit '[{...}]' as a string).
|
|
69
|
+
if (typeof edits === "string") {
|
|
70
|
+
const parsed = coerceStringToArray(edits);
|
|
71
|
+
|
|
72
|
+
if (parsed !== null) {
|
|
73
|
+
edits = parsed;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (isArray(edits)) {
|
|
78
|
+
const list: IReplacement[] = [];
|
|
79
|
+
|
|
80
|
+
for (const e of edits) {
|
|
81
|
+
if (
|
|
82
|
+
isRecord(e) &&
|
|
83
|
+
typeof e.oldString === "string" &&
|
|
84
|
+
typeof e.newString === "string"
|
|
85
|
+
) {
|
|
86
|
+
list.push({ oldString: e.oldString, newString: e.newString });
|
|
87
|
+
} else {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return list.length > 0 ? { file, edits: list } : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (typeof oldStr === "string" && typeof newStr === "string") {
|
|
96
|
+
return { file, edits: [{ oldString: oldStr, newString: newStr }] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function toCreate(args: Record<string, unknown>): ICreateFile | null {
|
|
103
|
+
const { content } = args;
|
|
104
|
+
const file = fileArg(args);
|
|
105
|
+
|
|
106
|
+
if (file !== null && typeof content === "string") {
|
|
107
|
+
return { file, content };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build L3 re-ask feedback for a broken tool call. Targets the exact field,
|
|
115
|
+
* shows what was received, names the expected type, and provides a working example.
|
|
116
|
+
*/
|
|
117
|
+
export function buildRepairFeedback(
|
|
118
|
+
toolName: string,
|
|
119
|
+
field: string,
|
|
120
|
+
received: unknown,
|
|
121
|
+
expectedType: string,
|
|
122
|
+
example: string
|
|
123
|
+
): string {
|
|
124
|
+
return (
|
|
125
|
+
`\n\n⚠ Tool argument repair failed — the \`${toolName}\` tool cannot proceed ` +
|
|
126
|
+
`without fixing \`${field}\`:\n` +
|
|
127
|
+
` received: ${JSON.stringify(received)} (${typeof received})\n` +
|
|
128
|
+
` expected: ${expectedType}\n` +
|
|
129
|
+
` example: \`${example}\`\n\n` +
|
|
130
|
+
`Fix the argument and call the tool again.`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function toRun(
|
|
135
|
+
args: Record<string, unknown>
|
|
136
|
+
): { command: string } | null {
|
|
137
|
+
const { command } = args;
|
|
138
|
+
|
|
139
|
+
return typeof command === "string" ? { command } : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function toRead(args: Record<string, unknown>): { file: string } | null {
|
|
143
|
+
const file = fileArg(args);
|
|
144
|
+
|
|
145
|
+
return file !== null ? { file } : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Parse hashline edit args: file (required), hash (optional), input (required).
|
|
150
|
+
*/
|
|
151
|
+
export function toHashlineEdit(
|
|
152
|
+
args: Record<string, unknown>
|
|
153
|
+
): { file: string; hash?: string; input: string } | null {
|
|
154
|
+
const file = fileArg(args);
|
|
155
|
+
const { hash, input } = args;
|
|
156
|
+
|
|
157
|
+
if (file === null || typeof input !== "string" || input.length === 0) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const hashStr =
|
|
162
|
+
typeof hash === "string" && hash.length > 0 ? hash : undefined;
|
|
163
|
+
|
|
164
|
+
return { file, hash: hashStr, input };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Run a shell command in `cwd` and capture stdout/stderr/exit — the `run` tool.
|
|
168
|
+
* Cancellable via `signal`; killed after a timeout (default `runToolTimeoutMs`)
|
|
169
|
+
* so a hung command can't wedge the harness. A timeout appends a clear note. */
|
|
170
|
+
export async function runCommand(
|
|
171
|
+
cwd: string,
|
|
172
|
+
command: string,
|
|
173
|
+
opts: { signal?: AbortSignal; timeoutMs?: number } = {}
|
|
174
|
+
): Promise<IShellResult> {
|
|
175
|
+
const timeoutMs = opts.timeoutMs ?? runToolTimeoutMs();
|
|
176
|
+
const run = await runShellCommand(cwd, command, {
|
|
177
|
+
timeoutMs,
|
|
178
|
+
...(opts.signal === undefined ? {} : { signal: opts.signal }),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const note = run.timedOut
|
|
182
|
+
? `\n[command killed after ${timeoutMs}ms timeout — TSFORGE_RUN_TIMEOUT_MS to change]`
|
|
183
|
+
: "";
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
stdout: run.stdout,
|
|
187
|
+
stderr: run.stderr + note,
|
|
188
|
+
exitCode: run.exitCode,
|
|
189
|
+
};
|
|
190
|
+
}
|