@agjs/tsforge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/bin/tsforge.js +2 -0
  2. package/package.json +35 -0
  3. package/src/agent/agent.constants.ts +382 -0
  4. package/src/agent/agent.types.ts +34 -0
  5. package/src/agent/index.ts +4 -0
  6. package/src/agent/model-agent.ts +297 -0
  7. package/src/agent/tool-repair.ts +194 -0
  8. package/src/agent/tools.ts +190 -0
  9. package/src/browser/checks.ts +96 -0
  10. package/src/browser/index.ts +8 -0
  11. package/src/browser/oracle.ts +303 -0
  12. package/src/classify.ts +48 -0
  13. package/src/cli.ts +1333 -0
  14. package/src/config/config.constants.ts +9 -0
  15. package/src/config/flags.ts +32 -0
  16. package/src/config/index.ts +8 -0
  17. package/src/config/tsforge-config.ts +301 -0
  18. package/src/constitution/baseline.ts +257 -0
  19. package/src/detect-gate.ts +498 -0
  20. package/src/eval/eval.types.ts +36 -0
  21. package/src/eval/index.ts +3 -0
  22. package/src/eval/judge.ts +62 -0
  23. package/src/eval/score.ts +39 -0
  24. package/src/files/create.ts +22 -0
  25. package/src/files/edit.ts +193 -0
  26. package/src/files/files.constants.ts +11 -0
  27. package/src/files/files.types.ts +81 -0
  28. package/src/files/hashline-format.ts +110 -0
  29. package/src/files/hashline.ts +689 -0
  30. package/src/files/index.ts +19 -0
  31. package/src/index.ts +8 -0
  32. package/src/inference/index.ts +6 -0
  33. package/src/inference/inference.constants.ts +34 -0
  34. package/src/inference/inference.types.ts +123 -0
  35. package/src/inference/openai-compatible.ts +113 -0
  36. package/src/inference/stream-guard.ts +161 -0
  37. package/src/inference/stream.ts +370 -0
  38. package/src/inference/transport.ts +78 -0
  39. package/src/inference/wire.ts +0 -0
  40. package/src/lib/fs/fs.ts +126 -0
  41. package/src/lib/fs/fs.types.ts +5 -0
  42. package/src/lib/fs/index.ts +3 -0
  43. package/src/lib/fs/process.ts +146 -0
  44. package/src/lib/guards/guards.ts +9 -0
  45. package/src/lib/guards/index.ts +1 -0
  46. package/src/lib/json/index.ts +1 -0
  47. package/src/lib/json/json.ts +12 -0
  48. package/src/lib/scope/index.ts +2 -0
  49. package/src/lib/scope/scope.constants.ts +3 -0
  50. package/src/lib/scope/scope.ts +40 -0
  51. package/src/loop/astgrep-fix.ts +228 -0
  52. package/src/loop/feedback/feedback.ts +138 -0
  53. package/src/loop/feedback/index.ts +8 -0
  54. package/src/loop/feedback/meta-rule-docs.ts +41 -0
  55. package/src/loop/feedback/meta-rule-feedback.ts +61 -0
  56. package/src/loop/feedback/rule-docs.generated.json +112 -0
  57. package/src/loop/feedback/rule-docs.ts +342 -0
  58. package/src/loop/index.ts +19 -0
  59. package/src/loop/loop.constants.ts +68 -0
  60. package/src/loop/loop.types.ts +99 -0
  61. package/src/loop/prompt/index.ts +2 -0
  62. package/src/loop/prompt/project-map.ts +69 -0
  63. package/src/loop/prompt/prompt.ts +107 -0
  64. package/src/loop/quality.ts +174 -0
  65. package/src/loop/rule-docs.generated.json +367 -0
  66. package/src/loop/run-spec.ts +88 -0
  67. package/src/loop/run.ts +400 -0
  68. package/src/loop/session.ts +1410 -0
  69. package/src/loop/tools/add-dependency.ts +71 -0
  70. package/src/loop/tools/condense.ts +498 -0
  71. package/src/loop/tools/edit-hashline.ts +80 -0
  72. package/src/loop/tools/execute-tool.ts +80 -0
  73. package/src/loop/tools/file-ops.ts +323 -0
  74. package/src/loop/tools/index.ts +2 -0
  75. package/src/loop/tools/lsp-ops.ts +222 -0
  76. package/src/loop/tools/scaffold-routes.ts +68 -0
  77. package/src/loop/tools/scaffold-ui.ts +62 -0
  78. package/src/loop/tools/scaffold-web.ts +35 -0
  79. package/src/loop/tools/tool-context.ts +126 -0
  80. package/src/loop/ttsr-defaults.ts +53 -0
  81. package/src/loop/ttsr.ts +322 -0
  82. package/src/loop/turn.ts +856 -0
  83. package/src/lsp/index.ts +2 -0
  84. package/src/lsp/lsp.types.ts +56 -0
  85. package/src/lsp/service.ts +500 -0
  86. package/src/meta-rules/context.ts +195 -0
  87. package/src/meta-rules/index.ts +9 -0
  88. package/src/meta-rules/meta-rules.types.ts +47 -0
  89. package/src/meta-rules/parsers/package-json-parser.ts +51 -0
  90. package/src/meta-rules/registry.ts +37 -0
  91. package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
  92. package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
  93. package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
  94. package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
  95. package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
  96. package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
  97. package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
  98. package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
  99. package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
  100. package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
  101. package/src/meta-rules/runner.ts +64 -0
  102. package/src/models-config.ts +196 -0
  103. package/src/render/ansi.ts +289 -0
  104. package/src/render/banner.ts +113 -0
  105. package/src/render/box.ts +134 -0
  106. package/src/render/index.ts +7 -0
  107. package/src/render/markdown.ts +123 -0
  108. package/src/render/render.types.ts +21 -0
  109. package/src/render/stream-markdown.ts +128 -0
  110. package/src/render/style.ts +26 -0
  111. package/src/rule-packs/bullmq/index.ts +39 -0
  112. package/src/rule-packs/bullmq/rules/index.ts +7 -0
  113. package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
  114. package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
  115. package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
  116. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
  117. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
  118. package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
  119. package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
  120. package/src/rule-packs/bullmq/utils.ts +334 -0
  121. package/src/rule-packs/code-flow/index.ts +25 -0
  122. package/src/rule-packs/code-flow/rules/index.ts +3 -0
  123. package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
  124. package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
  125. package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
  126. package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
  127. package/src/rule-packs/comment-hygiene/index.ts +25 -0
  128. package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
  129. package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
  130. package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
  131. package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
  132. package/src/rule-packs/create-rule.ts +9 -0
  133. package/src/rule-packs/drizzle/index.ts +41 -0
  134. package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
  135. package/src/rule-packs/drizzle/rules/index.ts +8 -0
  136. package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
  137. package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
  138. package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
  139. package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
  140. package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
  141. package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
  142. package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
  143. package/src/rule-packs/drizzle/utils.ts +115 -0
  144. package/src/rule-packs/elysia/index.ts +43 -0
  145. package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
  146. package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
  147. package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
  148. package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
  149. package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
  150. package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
  151. package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
  152. package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
  153. package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
  154. package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
  155. package/src/rule-packs/env-access/index.ts +23 -0
  156. package/src/rule-packs/env-access/rules/index.ts +2 -0
  157. package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
  158. package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
  159. package/src/rule-packs/i18n-keys/index.ts +19 -0
  160. package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
  161. package/src/rule-packs/index.ts +139 -0
  162. package/src/rule-packs/jwt-cookies/index.ts +25 -0
  163. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
  164. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
  165. package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
  166. package/src/rule-packs/jwt-cookies/utils.ts +188 -0
  167. package/src/rule-packs/oauth-security/index.ts +25 -0
  168. package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
  169. package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
  170. package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
  171. package/src/rule-packs/oauth-security/utils.ts +127 -0
  172. package/src/rule-packs/react-component-architecture/index.ts +35 -0
  173. package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
  174. package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
  175. package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
  176. package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
  177. package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
  178. package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
  179. package/src/rule-packs/react-component-architecture/utils.ts +47 -0
  180. package/src/rule-packs/rule-packs.types.ts +18 -0
  181. package/src/rule-packs/structured-logging/index.ts +26 -0
  182. package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
  183. package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
  184. package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
  185. package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
  186. package/src/rule-packs/tanstack-query/index.ts +20 -0
  187. package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
  188. package/src/rule-packs/test-conventions/index.ts +23 -0
  189. package/src/rule-packs/test-conventions/rules/index.ts +2 -0
  190. package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
  191. package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
  192. package/src/rule-packs/utils.ts +142 -0
  193. package/src/session-store.ts +359 -0
  194. package/src/spec/generate-tests.ts +213 -0
  195. package/src/spec/index.ts +5 -0
  196. package/src/spec/parse.ts +152 -0
  197. package/src/spec/review-tests.ts +162 -0
  198. package/src/spec/spec.constants.ts +13 -0
  199. package/src/spec/spec.types.ts +79 -0
  200. package/src/stack-detection/detect.ts +246 -0
  201. package/src/stack-detection/index.ts +3 -0
  202. package/src/stack-detection/packs.ts +174 -0
  203. package/src/stack-detection/stack-detection.types.ts +47 -0
  204. package/src/validate/accept.ts +49 -0
  205. package/src/validate/errors.ts +35 -0
  206. package/src/validate/index.ts +12 -0
  207. package/src/validate/parse.ts +148 -0
  208. package/src/validate/run-tests.ts +59 -0
  209. package/src/validate/validate.ts +40 -0
  210. package/src/validate/validate.types.ts +52 -0
  211. package/src/web-components.ts +638 -0
  212. package/src/web-coverage.ts +89 -0
  213. package/src/web-routes.ts +151 -0
  214. package/src/web-templates.ts +1011 -0
  215. package/strict.eslint.config.mjs +84 -0
  216. package/strict.web.eslint.config.mjs +185 -0
@@ -0,0 +1,80 @@
1
+ import type { IToolCall } from "../../inference";
2
+ import { TOOL_NAME, READ_ONLY_TOOL_NAMES, type ToolName } from "../../agent";
3
+ import { readFile, runShell, doEdit, doCreate } from "./file-ops";
4
+ import { doHashlineEdit } from "./edit-hashline";
5
+ import { doSearch, doLsp } from "./lsp-ops";
6
+ import { doScaffoldUi } from "./scaffold-ui";
7
+ import { doScaffoldRoutes } from "./scaffold-routes";
8
+ import { doScaffoldWeb } from "./scaffold-web";
9
+ import { doAddDependency } from "./add-dependency";
10
+ import { reject, type IToolContext } from "./tool-context";
11
+
12
+ export type { IToolContext } from "./tool-context";
13
+
14
+ type ToolHandler = (
15
+ args: Record<string, unknown>,
16
+ ctx: IToolContext
17
+ ) => Promise<string> | string;
18
+
19
+ /** Name → handler. The LSP entries close over their tool name so `doLsp` keeps
20
+ * one body. Keyed by ToolName, so a new tool must register here (exhaustive). */
21
+ const HANDLERS: Record<ToolName, ToolHandler> = {
22
+ [TOOL_NAME.read]: readFile,
23
+ [TOOL_NAME.run]: runShell,
24
+ [TOOL_NAME.edit]: doEdit,
25
+ [TOOL_NAME.editLines]: doHashlineEdit,
26
+ [TOOL_NAME.create]: doCreate,
27
+ [TOOL_NAME.search]: doSearch,
28
+ [TOOL_NAME.symbolSearch]: (a, c) => doLsp(TOOL_NAME.symbolSearch, a, c),
29
+ [TOOL_NAME.findReferences]: (a, c) => doLsp(TOOL_NAME.findReferences, a, c),
30
+ [TOOL_NAME.typeAt]: (a, c) => doLsp(TOOL_NAME.typeAt, a, c),
31
+ [TOOL_NAME.diagnostics]: (a, c) => doLsp(TOOL_NAME.diagnostics, a, c),
32
+ [TOOL_NAME.renameSymbol]: (a, c) => doLsp(TOOL_NAME.renameSymbol, a, c),
33
+ [TOOL_NAME.organizeImports]: (a, c) => doLsp(TOOL_NAME.organizeImports, a, c),
34
+ [TOOL_NAME.scaffoldUi]: doScaffoldUi,
35
+ [TOOL_NAME.scaffoldRoutes]: doScaffoldRoutes,
36
+ [TOOL_NAME.scaffoldWeb]: doScaffoldWeb,
37
+ [TOOL_NAME.addDependency]: doAddDependency,
38
+ // yield_status is intercepted by the Session BEFORE tool dispatch (it ends the
39
+ // turn); this handler only fires if one slips through with other calls.
40
+ [TOOL_NAME.yieldStatus]: () =>
41
+ "(turn continues — finish the work, then yield alone)",
42
+ };
43
+
44
+ function isToolName(name: string): name is ToolName {
45
+ return Object.hasOwn(HANDLERS, name);
46
+ }
47
+
48
+ /**
49
+ * Perform one tool call and return the text result fed back to the model as a
50
+ * tool message. Dispatch only — the handlers live in file-ops (read/run/edit/
51
+ * create) and lsp-ops (search + the semantic tools); scope enforcement and arg
52
+ * parsing live with each handler (tool-context holds the shared helpers).
53
+ */
54
+ export async function executeTool(
55
+ call: IToolCall,
56
+ ctx: IToolContext
57
+ ): Promise<string> {
58
+ if (!isToolName(call.name)) {
59
+ return `unknown tool: ${call.name}`;
60
+ }
61
+
62
+ // PLAN MODE hard guard: the advertised tool list already omits mutating tools,
63
+ // but a salvaged/forced call can name anything — reject it here so plan mode
64
+ // is a guarantee, not a convention. (`run` passes; its handler enforces a
65
+ // read-only command allowlist.)
66
+ if (
67
+ ctx.readOnly === true &&
68
+ !READ_ONLY_TOOL_NAMES.has(call.name) &&
69
+ call.name !== TOOL_NAME.run
70
+ ) {
71
+ return reject(
72
+ ctx,
73
+ call.name,
74
+ `plan mode: \`${call.name}\` is disabled — explore with read-only tools and ` +
75
+ "present your plan as text; the user must approve it before files can change."
76
+ );
77
+ }
78
+
79
+ return HANDLERS[call.name](call.arguments, ctx);
80
+ }
@@ -0,0 +1,323 @@
1
+ import { join } from "node:path";
2
+ import { applyEdits } from "../../files/edit";
3
+ import { applyCreate } from "../../files/create";
4
+ import { EDIT_FAIL_REASON } from "../../files";
5
+ import { writable, normalizeWorkspacePath } from "../../lib/scope";
6
+ import { LOOP_LIMITS } from "../loop.constants";
7
+ import { toEdits, toCreate, toRun, toRead, runCommand } from "../../agent";
8
+ import { ruleHelpFromOutput } from "../feedback/rule-docs";
9
+ import { condenseToolOutput } from "./condense";
10
+ import { parseOrRepair, reject, type IToolContext } from "./tool-context";
11
+ import { formatHashHeader, HL_LINE_SEP } from "../../files/hashline-format";
12
+ import { SessionSnapshotStore } from "../../files/hashline";
13
+ import { flags } from "../../config";
14
+
15
+ /**
16
+ * Read a file for the model. TRUSTED-MODE (by design): `read` and `run` are NOT
17
+ * sandboxed to the workspace — a `../config` read or any shell command the
18
+ * process can run is permitted, like a local human-run coding agent (Claude Code,
19
+ * etc.). Only WRITES (`edit`/`create`) are scope-enforced, since those are what
20
+ * mutate the user's project. tsforge runs locally on the user's own machine
21
+ * against their own code; the threat model is mistakes, not a hostile operator.
22
+ * (Sandboxing reads would be a separate, explicit execution profile.)
23
+ */
24
+ export async function readFile(
25
+ args: Record<string, unknown>,
26
+ ctx: IToolContext & { snapshotStore?: SessionSnapshotStore }
27
+ ): Promise<string> {
28
+ const { value: r, feedback } = parseOrRepair(args, toRead, ctx, "read");
29
+
30
+ if (r === null) {
31
+ if (feedback !== undefined && feedback.length > 0) {
32
+ return feedback;
33
+ }
34
+
35
+ return "read: malformed args (need `file`)";
36
+ }
37
+
38
+ r.file = normalizeWorkspacePath(ctx.cwd, r.file);
39
+
40
+ ctx.report({ kind: "tool", task: ctx.task, message: `read ${r.file}` });
41
+
42
+ const handle = Bun.file(join(ctx.cwd, r.file));
43
+
44
+ if (!(await handle.exists())) {
45
+ return `read: ${r.file} does not exist`;
46
+ }
47
+
48
+ const content = await handle.text();
49
+
50
+ // Annotate with hashline header if enabled
51
+ if (flags.hashlineEditTool()) {
52
+ ctx.snapshotStore ??= new SessionSnapshotStore();
53
+
54
+ const hash = ctx.snapshotStore.record(r.file, content);
55
+ const header = formatHashHeader(r.file, hash);
56
+ const lines = content.split("\n");
57
+ const annotated = lines
58
+ .map((line, i) => `${i + 1}${HL_LINE_SEP}${line}`)
59
+ .join("\n");
60
+
61
+ return `${header}\n${annotated}`;
62
+ }
63
+
64
+ return content;
65
+ }
66
+
67
+ /** Commands a plan-mode `run` may execute — pure inspection, never mutation. */
68
+ const READ_ONLY_COMMANDS = new Set([
69
+ "ls",
70
+ "cat",
71
+ "head",
72
+ "tail",
73
+ "wc",
74
+ "rg",
75
+ "grep",
76
+ "find",
77
+ "tree",
78
+ "stat",
79
+ "file",
80
+ "which",
81
+ "du",
82
+ "tsc",
83
+ "git",
84
+ ]);
85
+
86
+ /** Git subcommands that only inspect the repo. */
87
+ const READ_ONLY_GIT = new Set(["status", "log", "diff", "show", "branch"]);
88
+
89
+ /** Shell metacharacters that could turn a read into a write (`> out`, `&& rm`,
90
+ * `| tee`, command substitution). Their PRESENCE disqualifies — conservative. */
91
+ const SHELL_WRITE_RE = /[>;&|`]|\$\(/;
92
+
93
+ /**
94
+ * Deterministically read-only: exactly one allowlisted command, no redirects/
95
+ * pipes/chaining. Used by plan mode so the model can explore (`ls`, `rg`,
96
+ * `git log`, `tsc --noEmit`) without any path to mutating the workspace.
97
+ */
98
+ export function isReadOnlyCommand(command: string): boolean {
99
+ if (SHELL_WRITE_RE.test(command)) {
100
+ return false;
101
+ }
102
+
103
+ const [head, sub] = command.trim().split(/\s+/);
104
+
105
+ if (head === undefined || !READ_ONLY_COMMANDS.has(head)) {
106
+ return false;
107
+ }
108
+
109
+ if (head === "git") {
110
+ return sub !== undefined && READ_ONLY_GIT.has(sub);
111
+ }
112
+
113
+ return true;
114
+ }
115
+
116
+ export async function runShell(
117
+ args: Record<string, unknown>,
118
+ ctx: IToolContext
119
+ ): Promise<string> {
120
+ const { value: r, feedback } = parseOrRepair(args, toRun, ctx, "run");
121
+
122
+ if (r === null) {
123
+ if (feedback !== undefined && feedback.length > 0) {
124
+ return feedback;
125
+ }
126
+
127
+ return "run: malformed args (need `command`)";
128
+ }
129
+
130
+ if (ctx.readOnly === true && !isReadOnlyCommand(r.command)) {
131
+ return reject(
132
+ ctx,
133
+ "run",
134
+ "plan mode: only read-only commands are allowed (ls, cat, rg, grep, find, " +
135
+ `git status/log/diff/show/branch, tsc — no pipes/redirects). Blocked: ${r.command}`
136
+ );
137
+ }
138
+
139
+ const res = await runCommand(
140
+ ctx.cwd,
141
+ r.command,
142
+ ctx.signal === undefined ? {} : { signal: ctx.signal }
143
+ );
144
+ const raw = `${res.stdout}${res.stderr}`;
145
+ // Condition the output for the model through the single condensing pipeline
146
+ // (progress-strip → shape/per-tool condensers → signal-preserving truncation).
147
+ // Anything unrecognized — and any real FAILURE's errors — passes through.
148
+ const { text: output, via } = condenseToolOutput(
149
+ { command: r.command, output: raw, exitCode: res.exitCode },
150
+ LOOP_LIMITS.maxToolOutputChars
151
+ );
152
+
153
+ // Make the saving observable (and let us SPOT over-condensing in the log).
154
+ if (via !== null && output.length < raw.length) {
155
+ ctx.report({
156
+ kind: "tool",
157
+ task: ctx.task,
158
+ message: `condensed ${String(raw.length)}→${String(output.length)} chars via ${via}`,
159
+ });
160
+ }
161
+
162
+ // If the command surfaced lint/type errors, attach the failing rules' own
163
+ // bad→good docs to what the model reads — so it fixes from examples, not blind.
164
+ const help = res.exitCode === 0 ? "" : ruleHelpFromOutput(output);
165
+ const guidance =
166
+ help.length > 0 ? `\n\nFix guidance for the failing rules:\n${help}` : "";
167
+
168
+ // Log the guidance too (in the event output) so we can SEE the injection fire,
169
+ // not just feed it silently to the model.
170
+ ctx.report({
171
+ kind: "run",
172
+ task: ctx.task,
173
+ message: `$ ${r.command}`,
174
+ command: r.command,
175
+ exitCode: res.exitCode,
176
+ output: `${output}${guidance}`,
177
+ });
178
+
179
+ return `exit ${res.exitCode}\n${output}${guidance}`;
180
+ }
181
+
182
+ export async function doEdit(
183
+ args: Record<string, unknown>,
184
+ ctx: IToolContext
185
+ ): Promise<string> {
186
+ const { value: edit, feedback } = parseOrRepair(args, toEdits, ctx, "edit");
187
+
188
+ if (edit === null) {
189
+ if (feedback !== undefined && feedback.length > 0) {
190
+ return feedback;
191
+ }
192
+
193
+ return "edit: malformed args (need `file` plus either `oldString`/`newString` or an `edits` array of {oldString,newString})";
194
+ }
195
+
196
+ edit.file = normalizeWorkspacePath(ctx.cwd, edit.file);
197
+
198
+ if (!writable(edit.file, ctx.files)) {
199
+ return reject(
200
+ ctx,
201
+ "edit",
202
+ `edit ${edit.file} REJECTED: out of scope. You may only edit/create: ${ctx.files.join(", ")} (or throwaway files under scratch/).`
203
+ );
204
+ }
205
+
206
+ // The size cap is PER replacement — each piece must be surgical (no lazy
207
+ // whole-function rewrite) — but a batch may carry many pieces, so the model
208
+ // can fix the same issue at several spread-out sites in ONE turn.
209
+ for (let i = 0; i < edit.edits.length; i += 1) {
210
+ const span = (edit.edits[i]?.oldString ?? "").split("\n").length;
211
+
212
+ if (span > LOOP_LIMITS.maxEditLines) {
213
+ return reject(
214
+ ctx,
215
+ "edit",
216
+ `edit ${edit.file} REJECTED: replacement #${i + 1} is too large (${span} lines). Change ONLY the broken lines — make small, targeted replacements (the gate names the exact lines). To fix several spots, pass each as its own entry in \`edits\`; don't rewrite a whole function.`
217
+ );
218
+ }
219
+ }
220
+
221
+ const result = await applyEdits(ctx.cwd, edit.file, edit.edits);
222
+
223
+ if (result.ok) {
224
+ for (const r of edit.edits) {
225
+ ctx.report({
226
+ kind: "edit",
227
+ task: ctx.task,
228
+ file: edit.file,
229
+ message: `edit ${edit.file}`,
230
+ oldString: r.oldString,
231
+ newString: r.newString,
232
+ });
233
+ }
234
+
235
+ return `edited ${edit.file} (${result.count} change${result.count === 1 ? "" : "s"})`;
236
+ }
237
+
238
+ const where =
239
+ edit.edits.length > 1 ? ` (replacement #${result.index + 1})` : "";
240
+
241
+ return reject(
242
+ ctx,
243
+ `edit:${result.reason}`,
244
+ `edit ${edit.file} REJECTED${where}: ${editFailHelp(edit.file, result)}`
245
+ );
246
+ }
247
+
248
+ /**
249
+ * Turn an edit-failure reason into ACTIONABLE feedback. The bare reason strings
250
+ * ("not-found", "missing-file") were fatally ambiguous: a slow local model read
251
+ * an edit's "not-found" (= the oldString wasn't in the file) as "the FILE wasn't
252
+ * found", switched to `create`, hit "already exists", and thrashed edit↔create to
253
+ * the turn cap. Each message now says exactly what failed AND what to do next —
254
+ * crucially, whether the file exists (don't `create`) or not (do `create`).
255
+ */
256
+ function editFailHelp(
257
+ file: string,
258
+ result: { reason: string; matches?: number }
259
+ ): string {
260
+ if (result.reason === EDIT_FAIL_REASON.ambiguous) {
261
+ return `oldString matched ${result.matches ?? 0} places — include more surrounding lines to make it unique`;
262
+ }
263
+
264
+ if (result.reason === EDIT_FAIL_REASON.missingFile) {
265
+ return `the file ${file} does not exist yet — use \`create\` to make it (NOT edit)`;
266
+ }
267
+
268
+ if (result.reason === EDIT_FAIL_REASON.notFound) {
269
+ return `the file ${file} EXISTS, but your oldString text was not found in it. Do NOT use \`create\` (it already exists). \`read\` the file to see its exact current contents, then edit with text copied verbatim from it.`;
270
+ }
271
+
272
+ return result.reason;
273
+ }
274
+
275
+ export async function doCreate(
276
+ args: Record<string, unknown>,
277
+ ctx: IToolContext
278
+ ): Promise<string> {
279
+ const { value: create, feedback } = parseOrRepair(
280
+ args,
281
+ toCreate,
282
+ ctx,
283
+ "create"
284
+ );
285
+
286
+ if (create === null) {
287
+ if (feedback !== undefined && feedback.length > 0) {
288
+ return feedback;
289
+ }
290
+
291
+ return "create: malformed args (need file, content)";
292
+ }
293
+
294
+ create.file = normalizeWorkspacePath(ctx.cwd, create.file);
295
+
296
+ if (!writable(create.file, ctx.files)) {
297
+ return reject(
298
+ ctx,
299
+ "create",
300
+ `create ${create.file} REJECTED: out of scope. You may only edit/create: ${ctx.files.join(", ")} (or throwaway files under scratch/).`
301
+ );
302
+ }
303
+
304
+ const result = await applyCreate(ctx.cwd, create);
305
+
306
+ if (result.ok) {
307
+ ctx.report({
308
+ kind: "create",
309
+ task: ctx.task,
310
+ file: create.file,
311
+ message: `create ${create.file}`,
312
+ content: create.content,
313
+ });
314
+
315
+ return `created ${create.file}`;
316
+ }
317
+
318
+ return reject(
319
+ ctx,
320
+ "create:exists",
321
+ `create ${create.file} REJECTED: already exists — use \`edit\``
322
+ );
323
+ }
@@ -0,0 +1,2 @@
1
+ export { executeTool } from "./execute-tool";
2
+ export type { IToolContext } from "./tool-context";
@@ -0,0 +1,222 @@
1
+ import { relative } from "node:path";
2
+ import { fileArg, TOOL_NAME, type ToolName } from "../../agent";
3
+ import { runArgvCommand } from "../../lib/fs";
4
+ import { writable } from "../../lib/scope";
5
+ import { LOOP_LIMITS } from "../loop.constants";
6
+ import { str, reject, type IToolContext } from "./tool-context";
7
+
8
+ /** ripgrep search over the working dir — the model's primary navigation at
9
+ * scale (structural/text, fast). Falls back gracefully if `rg` is absent. */
10
+ export async function doSearch(
11
+ args: Record<string, unknown>,
12
+ ctx: IToolContext
13
+ ): Promise<string> {
14
+ const fromPattern = str(args, "pattern");
15
+ const pattern = fromPattern.length > 0 ? fromPattern : str(args, "query");
16
+
17
+ if (pattern.length === 0) {
18
+ return "search: malformed args (need `pattern`)";
19
+ }
20
+
21
+ const glob = str(args, "glob");
22
+ // Spawn rg with an explicit argv (NO shell) — the pattern/glob come from the
23
+ // model, so a `sh -c` string would let `$()`/backticks inside them execute.
24
+ const argv = [
25
+ "rg",
26
+ "--line-number",
27
+ "--no-heading",
28
+ "--color",
29
+ "never",
30
+ "-e",
31
+ pattern,
32
+ ...(glob.length > 0 ? ["-g", glob] : []),
33
+ ];
34
+ const res = await runArgvCommand(
35
+ ctx.cwd,
36
+ argv,
37
+ ctx.signal === undefined ? {} : { signal: ctx.signal }
38
+ );
39
+ // rg exits 1 when there are simply no matches (not an error); 2 = real error,
40
+ // 127 = rg absent. Surface stderr only on a real failure, not on "no matches".
41
+ const body = res.exitCode > 1 ? `${res.stdout}${res.stderr}` : res.stdout;
42
+ const out = body.slice(0, LOOP_LIMITS.maxToolOutputChars);
43
+
44
+ ctx.report({
45
+ kind: "tool",
46
+ task: ctx.task,
47
+ message: `search ${pattern}${glob.length > 0 ? ` (${glob})` : ""}`,
48
+ });
49
+
50
+ return out.trim().length > 0 ? out : `no matches for ${pattern}`;
51
+ }
52
+
53
+ /** Dispatch the semantic (LanguageService-backed) tools. The model addresses
54
+ * symbols by NAME + file; read-only tools are unrestricted, the two that WRITE
55
+ * (rename_symbol, organize_imports) are scope-enforced. */
56
+ export function doLsp(
57
+ name: ToolName,
58
+ args: Record<string, unknown>,
59
+ ctx: IToolContext
60
+ ): string {
61
+ const svc = ctx.tsService;
62
+
63
+ if (svc === undefined || svc === null) {
64
+ return `${name}: unavailable (this project has no TypeScript LanguageService)`;
65
+ }
66
+
67
+ const file = fileArg(args) ?? "";
68
+ const rel = (abs: string): string => relative(ctx.cwd, abs);
69
+
70
+ if (name === TOOL_NAME.symbolSearch) {
71
+ const q = str(args, "query");
72
+ const query = q.length > 0 ? q : str(args, "symbol");
73
+
74
+ if (query.length === 0) {
75
+ return "symbol_search: need `query`";
76
+ }
77
+
78
+ const hits = svc.symbols(query);
79
+
80
+ ctx.report({
81
+ kind: "tool",
82
+ task: ctx.task,
83
+ message: `symbol_search ${query} → ${hits.length}`,
84
+ });
85
+
86
+ return hits.length === 0
87
+ ? `no symbols matching '${query}'`
88
+ : hits
89
+ .map((h) => `${h.kind} ${h.name} — ${rel(h.file)}:${h.line}`)
90
+ .join("\n");
91
+ }
92
+
93
+ if (name === TOOL_NAME.diagnostics) {
94
+ if (file.length === 0) {
95
+ return "diagnostics: need `file`";
96
+ }
97
+
98
+ const diags = svc.diagnostics(file);
99
+
100
+ ctx.report({
101
+ kind: "tool",
102
+ task: ctx.task,
103
+ message: `diagnostics ${file} → ${diags.length}`,
104
+ });
105
+
106
+ return diags.length === 0
107
+ ? `no semantic diagnostics in ${file}`
108
+ : diags
109
+ .map((d) => `TS${d.code}: ${d.message.split("\n")[0] ?? d.message}`)
110
+ .join("\n");
111
+ }
112
+
113
+ // organize_imports operates on a whole FILE (no symbol/position) — handle it
114
+ // here, before doSymbolLsp's `need {file, symbol}` guard would wrongly reject a
115
+ // valid `{file}`-only call (its schema requires only `file`). See P1 review.
116
+ if (name === TOOL_NAME.organizeImports) {
117
+ if (file.length === 0) {
118
+ return "organize_imports: need `file`";
119
+ }
120
+
121
+ if (!writable(file, ctx.files)) {
122
+ return reject(
123
+ ctx,
124
+ "organize_imports",
125
+ `organize_imports ${file} REJECTED: out of scope.`
126
+ );
127
+ }
128
+
129
+ const n = svc.organizeImports(file);
130
+
131
+ ctx.report({
132
+ kind: "tool",
133
+ task: ctx.task,
134
+ message: `organize_imports ${file} (${n})`,
135
+ });
136
+
137
+ return `organize_imports: ${n} change(s) in ${file}`;
138
+ }
139
+
140
+ // The remaining tools address a symbol by name within a file.
141
+ return doSymbolLsp(name, svc, file, args, ctx, rel);
142
+ }
143
+
144
+ type LspService = NonNullable<IToolContext["tsService"]>;
145
+
146
+ /** The LSP tools that resolve a symbol position first (type_at, find_references,
147
+ * rename_symbol). Split out of doLsp to keep each function's branching small. */
148
+ function doSymbolLsp(
149
+ name: ToolName,
150
+ svc: LspService,
151
+ file: string,
152
+ args: Record<string, unknown>,
153
+ ctx: IToolContext,
154
+ rel: (abs: string) => string
155
+ ): string {
156
+ const symbol = str(args, "symbol");
157
+
158
+ if (file.length === 0 || symbol.length === 0) {
159
+ return `${name}: need {file, symbol}`;
160
+ }
161
+
162
+ const pos = svc.positionOfSymbol(file, symbol);
163
+
164
+ if (pos === undefined) {
165
+ return `${name}: '${symbol}' not found in ${file}`;
166
+ }
167
+
168
+ if (name === TOOL_NAME.typeAt) {
169
+ const type = svc.typeAt(file, pos);
170
+
171
+ ctx.report({ kind: "tool", task: ctx.task, message: `type_at ${symbol}` });
172
+
173
+ return type.length > 0
174
+ ? `${symbol}: ${type}`
175
+ : `no type info for ${symbol}`;
176
+ }
177
+
178
+ if (name === TOOL_NAME.findReferences) {
179
+ const refs = svc.references(file, pos);
180
+
181
+ ctx.report({
182
+ kind: "tool",
183
+ task: ctx.task,
184
+ message: `find_references ${symbol} → ${refs.length}`,
185
+ });
186
+
187
+ return refs.length === 0
188
+ ? `no references to '${symbol}'`
189
+ : refs.map((r) => `${rel(r.file)}:${r.line}`).join("\n");
190
+ }
191
+
192
+ // rename_symbol — scope-enforced: a semantic rename can touch many files; it
193
+ // must NOT edit read-only/out-of-scope ones.
194
+ const newName = str(args, "newName");
195
+
196
+ if (newName.length === 0) {
197
+ return "rename_symbol: need `newName`";
198
+ }
199
+
200
+ const targets = svc.renameTargets(file, pos).map(rel);
201
+ const outOfScope = targets.filter((t) => !writable(t, ctx.files));
202
+
203
+ if (outOfScope.length > 0) {
204
+ return reject(
205
+ ctx,
206
+ "rename_symbol",
207
+ `rename '${symbol}' REJECTED: would edit out-of-scope/read-only file(s): ${outOfScope.join(", ")}. Rename only symbols whose every reference is in your editable files.`
208
+ );
209
+ }
210
+
211
+ const changed = svc.rename(file, pos, newName);
212
+
213
+ ctx.report({
214
+ kind: "tool",
215
+ task: ctx.task,
216
+ message: `rename_symbol ${symbol}→${newName} (${changed ?? 0})`,
217
+ });
218
+
219
+ return changed === null
220
+ ? `rename_symbol: '${symbol}' can't be renamed here`
221
+ : `renamed '${symbol}' → '${newName}' across ${changed} location(s)`;
222
+ }
@@ -0,0 +1,68 @@
1
+ import { join } from "node:path";
2
+ import { writable, normalizeWorkspacePath } from "../../lib/scope";
3
+ import { materializeRoutes, asRoutePaths } from "../../web-routes";
4
+ import { type IToolContext } from "./tool-context";
5
+
6
+ /**
7
+ * `scaffold_routes` — materialize ALL of an app's routes as file-based stubs from
8
+ * a model-declared path list, so the route union is complete up front and no
9
+ * `<Link to>` can forward-reference a missing route (the error class that ate ~1/3
10
+ * of multi-route builds). The model owns WHAT (which pages — app-specific); this
11
+ * owns HOW (correct createFileRoute + $param filename mapping). Like scaffold_ui:
12
+ * overwrites stubs idempotently and reports ONE summary event (not per-file
13
+ * `create`s) so the write-guard doesn't re-check the generated shells.
14
+ */
15
+ export async function doScaffoldRoutes(
16
+ args: Record<string, unknown>,
17
+ ctx: IToolContext
18
+ ): Promise<string> {
19
+ const paths = asRoutePaths(args.routes);
20
+
21
+ if (paths.length === 0) {
22
+ return 'scaffold_routes REJECTED: `routes` must be a non-empty array of path strings (e.g. ["/", "/accounts", "/accounts/$accountId"]).';
23
+ }
24
+
25
+ const files = materializeRoutes(paths);
26
+ const written: string[] = [];
27
+ const kept: string[] = [];
28
+
29
+ for (const [rel, content] of Object.entries(files)) {
30
+ const path = normalizeWorkspacePath(ctx.cwd, rel);
31
+
32
+ if (!writable(path, ctx.files)) {
33
+ continue;
34
+ }
35
+
36
+ // NEVER overwrite an existing route file — it may already be FILLED with the
37
+ // real page. scaffold_routes is idempotent + additive: it only stubs routes
38
+ // that don't exist yet. (Re-calling it to add new pages used to clobber every
39
+ // already-built page back to a stub → an endless re-fill loop.)
40
+ if (await Bun.file(join(ctx.cwd, path)).exists()) {
41
+ kept.push(path);
42
+ continue;
43
+ }
44
+
45
+ await Bun.write(join(ctx.cwd, path), content);
46
+ written.push(path);
47
+ }
48
+
49
+ const keptNote =
50
+ kept.length > 0
51
+ ? ` (kept ${String(kept.length)} existing route(s) untouched)`
52
+ : "";
53
+
54
+ ctx.report({
55
+ kind: "tool",
56
+ task: ctx.task,
57
+ message: `scaffold_routes: created ${String(written.length)} new route stub(s)${keptNote}`,
58
+ });
59
+
60
+ return (
61
+ `scaffold_routes: created ${String(written.length)} NEW route stub(s)${keptNote}. ` +
62
+ `It is ADDITIVE and safe to call again — it NEVER overwrites a route you've already ` +
63
+ `built, only adds missing ones. New stubs are PLACEHOLDERS (data-tsforge-stub): replace ` +
64
+ `EACH with the real page (its list/detail/form using your types + useCollection(service) ` +
65
+ `+ @/components/ui). The gate FAILS while any stub remains. To FILL a route, EDIT its file ` +
66
+ `directly — do NOT call scaffold_routes again to "reset" it.`
67
+ );
68
+ }