@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,400 @@
1
+ import type { ITask } from "../spec";
2
+ import type { IChatMessage, IModelResponse, IProvider } from "../inference";
3
+ import { validate, type ErrorParser } from "../validate";
4
+ import { parseEslintJson } from "../validate";
5
+ import { readFiles } from "../lib/fs";
6
+ import { RUN_STATUS, STUCK_REASON, LOOP_LIMITS } from "./loop.constants";
7
+ import type { IRunResult, IRunOptions, Reporter } from "./loop.types";
8
+ import { flags } from "../config";
9
+ import { SYSTEM, seedPrompt } from "./prompt";
10
+ import { detectStack } from "../stack-detection";
11
+ import { TtsrManager } from "./ttsr";
12
+ import { DEFAULT_TTSR_RULES } from "./ttsr-defaults";
13
+ import {
14
+ type ILoopCtx,
15
+ type ILoopState,
16
+ toolsFor,
17
+ buildTsService,
18
+ runToolCalls,
19
+ settleGate,
20
+ emitTiming,
21
+ NO_TOOL_CALL_NUDGE,
22
+ } from "./turn";
23
+
24
+ /** Report any salvaged malformed tool calls, then stop the task if the stream
25
+ * degenerated into a repetition loop (returns a terminal stuck result; null to
26
+ * keep going) — mirrors the interactive Session's degeneration handling. */
27
+ function handleDegeneration(
28
+ res: IModelResponse,
29
+ ctx: ILoopCtx,
30
+ state: ILoopState,
31
+ at: { turn: number; turnStart: number; taskStart: number }
32
+ ): IRunResult | null {
33
+ const { report } = ctx;
34
+ const { id } = ctx.task;
35
+
36
+ if (res.salvaged !== undefined && res.salvaged > 0) {
37
+ report({
38
+ kind: "tool",
39
+ task: id,
40
+ message: `recovered ${res.salvaged} malformed tool call(s) (server tool-call parser mismatch)`,
41
+ });
42
+ }
43
+
44
+ if (res.degenerated !== true) {
45
+ return null;
46
+ }
47
+
48
+ report({
49
+ kind: "stuck",
50
+ task: id,
51
+ cycles: at.turn,
52
+ message:
53
+ "model fell into a repetition loop - stopped. Try a smaller task or steer it with a narrower instruction.",
54
+ });
55
+ emitTiming(report, id, at.turn, at.turnStart, at.taskStart);
56
+
57
+ return {
58
+ task: id,
59
+ redConfirmed: true,
60
+ status: RUN_STATUS.stuck,
61
+ cycles: at.turn,
62
+ reason: STUCK_REASON.stalled,
63
+ edits: state.edits,
64
+ regressions: state.regressions,
65
+ };
66
+ }
67
+
68
+ /** Build and configure a TTSR manager if enabled. Returns null if disabled. */
69
+ function initTtsrManager(): TtsrManager | null {
70
+ if (!flags.ttsr()) {
71
+ return null;
72
+ }
73
+
74
+ const manager = new TtsrManager();
75
+
76
+ for (const rule of DEFAULT_TTSR_RULES) {
77
+ manager.addRule(rule);
78
+ }
79
+
80
+ return manager;
81
+ }
82
+
83
+ /** Handle a TTSR interrupt: report, inject corrective message, and optionally disable. */
84
+ function handleTtsrInterrupt(
85
+ ttsrFired: { ruleName: string; guidance: string },
86
+ state: ILoopState,
87
+ messages: IChatMessage[],
88
+ report: Reporter,
89
+ taskId: string,
90
+ turn: number,
91
+ turnStart: number,
92
+ taskStart: number,
93
+ ttsrManager: TtsrManager | null
94
+ ): void {
95
+ state.ttsrInterrupts += 1;
96
+
97
+ report({
98
+ kind: "ttsr",
99
+ task: taskId,
100
+ message: `⚠ TTSR interrupted: ${ttsrFired.ruleName}`,
101
+ });
102
+
103
+ // Hard cap: after 3 interrupts, disable TTSR to prevent loops
104
+ if (state.ttsrInterrupts >= 3) {
105
+ report({
106
+ kind: "tool",
107
+ task: taskId,
108
+ message: `TTSR disabled after ${state.ttsrInterrupts} interrupts (hit cap)`,
109
+ });
110
+
111
+ ttsrManager?.disable();
112
+ }
113
+
114
+ // Append corrective message and retry without counting as a normal cycle
115
+ messages.push({
116
+ role: "user",
117
+ content: `⚠ generation interrupted: ${ttsrFired.guidance} Rewrite the affected part without that pattern.`,
118
+ });
119
+
120
+ emitTiming(report, taskId, turn, turnStart, taskStart);
121
+ }
122
+
123
+ /** Assemble per-call completion options, leaving optional knobs unset when absent. */
124
+ function completionOptionsFor(args: {
125
+ tools: unknown[];
126
+ temperature: number;
127
+ enableThinking: boolean | undefined;
128
+ thinkingTokenBudget: number | undefined;
129
+ ttsrManager: TtsrManager | null;
130
+ report: Reporter;
131
+ taskId: string;
132
+ }): Parameters<IProvider["complete"]>[1] {
133
+ return {
134
+ tools: args.tools,
135
+ temperature: args.temperature,
136
+ toolChoice: "auto",
137
+ ...(args.enableThinking === undefined
138
+ ? {}
139
+ : { enableThinking: args.enableThinking }),
140
+ ...(args.thinkingTokenBudget === undefined
141
+ ? {}
142
+ : { thinkingTokenBudget: args.thinkingTokenBudget }),
143
+ ...(args.ttsrManager === null ? {} : { ttsrManager: args.ttsrManager }),
144
+ onToken: (text) => {
145
+ args.report({ kind: "token", task: args.taskId, message: text });
146
+ },
147
+ };
148
+ }
149
+
150
+ /** A/B control for the gate-feedback-fidelity win: TSFORGE_LEGACY_FEEDBACK=1
151
+ * forces the OLD mis-selected parser (eslint-json on chained tsc&&eslint). */
152
+ function effectiveParserFor(
153
+ parse: ErrorParser | undefined
154
+ ): ErrorParser | undefined {
155
+ return flags.legacyFeedback() ? parseEslintJson : parse;
156
+ }
157
+
158
+ /** Detect the stack and fold in tsforge.config.json pack/rule overrides. */
159
+ async function resolveStackForRun(cwd: string): Promise<{
160
+ stackProfile: Awaited<ReturnType<typeof detectStack>>;
161
+ ruleOverrides: Readonly<Record<string, "error" | "warn" | "off">>;
162
+ }> {
163
+ const detectedProfile = await detectStack(cwd);
164
+ const { loadTsforgeConfig, resolveActivePacks, normalizeRuleOverrides } =
165
+ await import("../config/tsforge-config");
166
+ const cfg = await loadTsforgeConfig(cwd);
167
+
168
+ return {
169
+ stackProfile: {
170
+ ...detectedProfile,
171
+ packs: resolveActivePacks(detectedProfile.packs, cfg),
172
+ },
173
+ ruleOverrides: normalizeRuleOverrides(cfg),
174
+ };
175
+ }
176
+
177
+ /**
178
+ * The implement loop as a persistent, tool-using conversation. The model drives
179
+ * — it can `read`, `run` (tests/tsc/eslint), `edit`, `create` — and the whole
180
+ * conversation is retained as memory. When it stops calling tools (believes it's
181
+ * done), the harness runs the deterministic gate, which is the ONLY authority on
182
+ * "done": green ⇒ finished; red ⇒ the errors go back into the conversation and it
183
+ * continues. It can't fake completion.
184
+ *
185
+ * This is the RED-first, drive-to-green wrapper the EVAL harness uses; the
186
+ * interactive CLI composes the same `turn.ts` primitives via `Session`.
187
+ */
188
+ export async function runTask(
189
+ task: ITask,
190
+ cwd: string,
191
+ provider: IProvider,
192
+ opts: IRunOptions = {}
193
+ ): Promise<IRunResult> {
194
+ const { parse, enableThinking, thinkingTokenBudget } = opts;
195
+ const effectiveParse = effectiveParserFor(parse);
196
+ const temperature = opts.temperature ?? 0;
197
+ const maxTurns = opts.maxTurns ?? LOOP_LIMITS.maxTurns;
198
+ const report: Reporter = opts.onEvent ?? (() => undefined);
199
+
200
+ report({
201
+ kind: "start",
202
+ task: task.id,
203
+ message: `task ${task.id}: checking current state`,
204
+ });
205
+
206
+ // RED: the goalpost must fail before we build.
207
+ const red = await validate(task, cwd, effectiveParse);
208
+
209
+ if (red.passed) {
210
+ report({
211
+ kind: "done",
212
+ task: task.id,
213
+ cycles: 0,
214
+ message: `task ${task.id}: already green`,
215
+ });
216
+
217
+ return {
218
+ task: task.id,
219
+ redConfirmed: false,
220
+ status: RUN_STATUS.redNotConfirmed,
221
+ cycles: 0,
222
+ edits: 0,
223
+ regressions: 0,
224
+ };
225
+ }
226
+
227
+ report({
228
+ kind: "red",
229
+ task: task.id,
230
+ errors: red.errors.length,
231
+ message: `task ${task.id}: RED (${red.errors.length} error(s))`,
232
+ });
233
+
234
+ // Detect stack once per run, early; tsforge.config.json may adjust it
235
+ const { stackProfile, ruleOverrides } = await resolveStackForRun(cwd);
236
+
237
+ report({
238
+ kind: "tool",
239
+ task: task.id,
240
+ message: `detected stack: ${stackProfile.name} (${stackProfile.reason})`,
241
+ });
242
+
243
+ const editable = await readFiles(cwd, task.files);
244
+ const context = await readFiles(cwd, task.context ?? []);
245
+ const messages: IChatMessage[] = [
246
+ { role: "system", content: SYSTEM },
247
+ {
248
+ role: "user",
249
+ content: seedPrompt(task, editable, context, stackProfile),
250
+ },
251
+ ];
252
+
253
+ // Existing code to navigate? (editable files already have content). Only then
254
+ // do the LSP nav tools earn their decision-surface cost — see toolsFor().
255
+ const hasExistingCode = editable.some((f) => f.content.trim().length > 0);
256
+ const tools = toolsFor(hasExistingCode);
257
+
258
+ // Mode-aware reasoning cap: scratch tasks over-think unbounded, so default
259
+ // them to the measured knee; existing-code runs stay uncapped (the cap hurts
260
+ // navigation). An explicit opts.thinkingTokenBudget always wins.
261
+ const effectiveThinkingBudget =
262
+ thinkingTokenBudget ??
263
+ (hasExistingCode ? undefined : LOOP_LIMITS.scratchThinkingBudget);
264
+
265
+ const ttsrManager = initTtsrManager();
266
+
267
+ const ctx: ILoopCtx = {
268
+ task,
269
+ cwd,
270
+ tsService: await buildTsService(cwd),
271
+ parse: effectiveParse,
272
+ report,
273
+ messages,
274
+ stackProfile,
275
+ ruleOverrides:
276
+ Object.keys(ruleOverrides).length > 0 ? ruleOverrides : undefined,
277
+ };
278
+ const state: ILoopState = {
279
+ prevGateErrors: red.errors,
280
+ gateNoProgress: 0,
281
+ lastGateCount: -1,
282
+ edits: 0,
283
+ regressions: 0,
284
+ ttsrInterrupts: 0,
285
+ };
286
+ const taskStart = performance.now();
287
+
288
+ for (let turn = 1; turn <= maxTurns; turn += 1) {
289
+ const turnStart = performance.now();
290
+
291
+ report({
292
+ kind: "cycle",
293
+ task: task.id,
294
+ cycle: turn,
295
+ message: `task ${task.id} · turn ${turn}: asking model`,
296
+ });
297
+
298
+ ttsrManager?.resetBuffer();
299
+
300
+ const res = await provider.complete(
301
+ messages,
302
+ completionOptionsFor({
303
+ tools,
304
+ temperature,
305
+ enableThinking,
306
+ thinkingTokenBudget: effectiveThinkingBudget,
307
+ ttsrManager,
308
+ report,
309
+ taskId: task.id,
310
+ })
311
+ );
312
+
313
+ messages.push({
314
+ role: "assistant",
315
+ content: res.content,
316
+ toolCalls: res.toolCalls,
317
+ });
318
+
319
+ // Every model call advances cooldown accounting — including interrupted
320
+ // ones, otherwise repeatGap rules mis-count after a TTSR retry.
321
+ ttsrManager?.incrementTurnCount();
322
+
323
+ // TTSR interrupts are checked BEFORE degeneration so corrective guidance
324
+ // lands at the earliest point. If the TTSR retry itself degenerates, the
325
+ // next iteration's degeneration check catches it.
326
+ if (res.ttsrFired !== undefined) {
327
+ handleTtsrInterrupt(
328
+ res.ttsrFired,
329
+ state,
330
+ messages,
331
+ report,
332
+ task.id,
333
+ turn,
334
+ turnStart,
335
+ taskStart,
336
+ ttsrManager
337
+ );
338
+
339
+ // Continue to next turn without settling the gate
340
+ continue;
341
+ }
342
+
343
+ const looped = handleDegeneration(res, ctx, state, {
344
+ turn,
345
+ turnStart,
346
+ taskStart,
347
+ });
348
+
349
+ if (looped !== null) {
350
+ return looped;
351
+ }
352
+
353
+ const touchedEditable =
354
+ res.toolCalls.length === 0
355
+ ? false
356
+ : await runToolCalls(res.toolCalls, ctx, state);
357
+
358
+ // Settle the gate whenever the model stopped OR changed an editable file.
359
+ // (A read-only turn neither finishes nor mutates — just loop again.)
360
+ if (res.toolCalls.length === 0 || touchedEditable) {
361
+ const settled = await settleGate(ctx, state, turn);
362
+
363
+ emitTiming(report, task.id, turn, turnStart, taskStart);
364
+
365
+ if (settled !== null) {
366
+ return {
367
+ ...settled,
368
+ edits: state.edits,
369
+ regressions: state.regressions,
370
+ };
371
+ }
372
+
373
+ // Stopped with no tool call while still red → nudge it to act, not narrate.
374
+ if (res.toolCalls.length === 0) {
375
+ messages.push({ role: "user", content: NO_TOOL_CALL_NUDGE });
376
+ }
377
+
378
+ continue;
379
+ }
380
+
381
+ emitTiming(report, task.id, turn, turnStart, taskStart);
382
+ }
383
+
384
+ report({
385
+ kind: "stuck",
386
+ task: task.id,
387
+ cycles: maxTurns,
388
+ message: `task ${task.id}: stuck (hit ${maxTurns}-turn cap)`,
389
+ });
390
+
391
+ return {
392
+ task: task.id,
393
+ redConfirmed: true,
394
+ status: RUN_STATUS.stuck,
395
+ cycles: maxTurns,
396
+ reason: STUCK_REASON.cap,
397
+ edits: state.edits,
398
+ regressions: state.regressions,
399
+ };
400
+ }