@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,856 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import type { ITask } from "../spec";
4
+ import type { IChatMessage, IToolCall } from "../inference";
5
+ import {
6
+ validate,
7
+ runAccept,
8
+ sameErrorSet,
9
+ type ErrorParser,
10
+ type ErrorSet,
11
+ } from "../validate";
12
+ import { isInScope } from "../lib/scope";
13
+ import { fileExists, resolveScopeFiles } from "../lib/fs";
14
+ import { RUN_STATUS, STUCK_REASON, LOOP_LIMITS } from "./loop.constants";
15
+ import type { IRunResult, Reporter } from "./loop.types";
16
+ import { flags } from "../config";
17
+ import type { IStackProfile } from "../stack-detection";
18
+ import { gateFeedback } from "./feedback";
19
+ import { executeTool } from "./tools";
20
+ import {
21
+ astGrepFix,
22
+ dropRedundantAnnotations,
23
+ stripLiteralCasts,
24
+ } from "./astgrep-fix";
25
+ import {
26
+ EDIT_TOOL,
27
+ EDIT_LINES_TOOL,
28
+ CREATE_TOOL,
29
+ RUN_TOOL,
30
+ READ_TOOL,
31
+ LSP_TOOLS,
32
+ TOOL_NAME,
33
+ } from "../agent";
34
+ import { TsService, type ITsDiagnostic } from "../lsp";
35
+ import type { FileLinter, IFileLintProblem } from "../detect-gate";
36
+ import {
37
+ buildMetaRuleContext,
38
+ runMetaRules,
39
+ META_RULES,
40
+ type IMetaRuleViolation,
41
+ } from "../meta-rules";
42
+
43
+ /**
44
+ * The shared turn primitives — one tool-using-conversation step and the
45
+ * deterministic gate that confirms "done". Both drivers compose these: `runTask`
46
+ * (the RED-first, drive-to-green eval wrapper in run.ts) and the interactive
47
+ * `Session` (the CLI's persistent conversation). Keeping them here means there is
48
+ * exactly ONE turn-loop and ONE gate, never two implementations to drift apart.
49
+ */
50
+
51
+ // The base tools the model always has, plus the semantic LSP/search tools
52
+ // (rename/type_at/find_references/symbol_search/diagnostics/organize_imports/
53
+ // search). The LSP set is for NAVIGATING an existing codebase. Measured (money
54
+ // vs react-board, 2026-06-06): handing the 7 nav tools to a SCRATCH create-from-
55
+ // spec task DILUTES the create path — the small model narrates/explores
56
+ // ("let me check existing files…") instead of emitting `create`, and stalls.
57
+ // react-board (existing code) used them cleanly. So gate them on whether there
58
+ // is existing code to navigate. TSFORGE_NO_LSP_TOOLS=1 forces them off entirely.
59
+ const BASE_TOOLS = [READ_TOOL, RUN_TOOL, EDIT_TOOL, CREATE_TOOL];
60
+
61
+ const HASHLINE_TOOLS = flags.hashlineEditTool() ? [EDIT_LINES_TOOL] : [];
62
+
63
+ const ALL_TOOLS = [...BASE_TOOLS, ...HASHLINE_TOOLS, ...LSP_TOOLS];
64
+
65
+ export function toolsFor(hasExistingCode: boolean): typeof ALL_TOOLS {
66
+ if (flags.noLspTools() || !hasExistingCode) {
67
+ return [...BASE_TOOLS, ...HASHLINE_TOOLS];
68
+ }
69
+
70
+ return ALL_TOOLS;
71
+ }
72
+
73
+ /** The model wrote prose but issued NO tool call while the gate is still red —
74
+ * a narration-without-action turn (seen on money + react-board). Nudge it to ACT. */
75
+ export const NO_TOOL_CALL_NUDGE =
76
+ "You replied with text but called no tool. Writing code or a plan in your " +
77
+ "message does NOT change any file. Don't describe the next step — emit the " +
78
+ "actual tool call now (create/edit to change a file, read/search to inspect one).";
79
+
80
+ /** A build turn ended with the model writing whole files INTO its chat message
81
+ * (fenced code blocks) instead of calling `create` — the narrate-instead-of-build
82
+ * failure. A chat message is never written to disk, so this nudges it to act. */
83
+ export const BUILD_NUDGE =
84
+ "STOP — you wrote file contents in your message, but that does NOT create any " +
85
+ "files on disk and cannot run. Write them for real now: call `create` once per " +
86
+ "file (relative path + full contents), ONE file per call, starting with the " +
87
+ "first. Do not paste code into your reply again — emit the create tool call.";
88
+
89
+ /** The coordinator's per-task working context (immutable inputs). */
90
+ export interface ILoopCtx {
91
+ task: ITask;
92
+ cwd: string;
93
+ tsService: TsService | null;
94
+ /** Write-time single-file linter (the gate's eslint rules, applied per write so
95
+ * moat violations tsc can't see surface inline). Omitted ⇒ type-only guard. */
96
+ lintFile?: FileLinter;
97
+ parse: ErrorParser | undefined;
98
+ report: Reporter;
99
+ messages: IChatMessage[];
100
+ /** Detected stack profile — determines which rule packs are enabled. */
101
+ stackProfile?: IStackProfile;
102
+ /** Rule severity overrides from tsforge.config.json (maps rule ID to "error" | "warn" | "off"). */
103
+ ruleOverrides?: Readonly<Record<string, "error" | "warn" | "off">>;
104
+ /** When set, the gate's command output is streamed here live (the CLI wires
105
+ * this so a slow gate like `vite build` + browser isn't silent dead air).
106
+ * Omitted on the eval path, where output is just captured for scoring. */
107
+ onGateChunk?: (text: string) => void;
108
+ /** Cancellation for the in-flight turn — threaded into tool `run` commands and
109
+ * the gate so a Ctrl-C (or a kill-timeout) reaches the child processes, not
110
+ * just the model call. Set per-send by the Session. */
111
+ signal?: AbortSignal;
112
+ /** Wired by the interactive CLI: turn this workspace into a web project (the
113
+ * `scaffold_web` tool calls it). Threaded into the tool context. */
114
+ setupWeb?: (framework: string) => Promise<void>;
115
+ /** PLAN MODE (set via Session.setPlanMode): threaded into the tool context so
116
+ * mutating tools are rejected at dispatch — the model only plans. */
117
+ readOnly?: boolean;
118
+ }
119
+
120
+ /** Mutable state threaded across turns (the gradient the loop descends). */
121
+ export interface ILoopState {
122
+ prevGateErrors: ErrorSet;
123
+ gateNoProgress: number;
124
+ lastGateCount: number;
125
+ edits: number;
126
+ regressions: number;
127
+ /** Count of TTSR rule interrupts this task. Hard cap at 3 to prevent loops. */
128
+ ttsrInterrupts: number;
129
+ }
130
+
131
+ /** Build the in-process TS LanguageService if the project has a tsconfig. Guarded
132
+ * so a setup failure can't break the loop (the `tsc -p` gate stays authority). */
133
+ export async function buildTsService(cwd: string): Promise<TsService | null> {
134
+ try {
135
+ if (await fileExists(cwd, "tsconfig.json")) {
136
+ return new TsService(cwd);
137
+ }
138
+ } catch {
139
+ // degrade silently — the gate runs regardless
140
+ }
141
+
142
+ return null;
143
+ }
144
+
145
+ /** Diagnostic codes that are EXPECTED noise mid-build, not real mistakes: 2307 =
146
+ * "cannot find module" (a sibling the model hasn't created yet in this batch). */
147
+ const TRANSIENT_DIAG_CODES = new Set<number>([2307]);
148
+
149
+ /**
150
+ * The write-guard and interim check run `tsc` WITHOUT a build, so they see the STUB
151
+ * `routeTree.gen.ts` (only `/` + `__root__` registered). TanStack Router's types are
152
+ * driven by that tree, so EVERY route-API type usage is a phantom error there — and
153
+ * route correctness can ONLY be validated against the real tree, which the gate's
154
+ * build-first step regenerates. So we ignore ALL route-API type errors at write/
155
+ * interim time; the GATE still validates routes for real.
156
+ *
157
+ * Measured across overnight CRM builds, these phantoms took MANY message shapes
158
+ * (≈51 of ~67 errors in build #2 alone): `to` unions in any order (`"/" | "." | ".."`,
159
+ * `"." | ".." | "/"`), bare `"/"` params, `createFileRoute` constraints, and
160
+ * `params={{…}}` excess-property errors. Path/shape matching was whack-a-mole, so we
161
+ * match the ROUTER'S INTERNAL TYPE SIGNATURES instead — robust to ordering and form.
162
+ * SAFE: a genuinely wrong route still fails the gate (real tree); we only suppress
163
+ * the un-fixable write/interim noise the model would otherwise chase.
164
+ */
165
+ const ROUTE_API_SIGNATURES: readonly RegExp[] = [
166
+ /ConstrainLiteral</, // `<Link to>` / createFileRoute path constraint
167
+ /ParamsReducerFn</, // `params={{…}}` on a typed route
168
+ /"__root__"/, // stub route-id union (`"__root__" | "/"`)
169
+ /keyof FileRoutesByPath/, // navigate({ to }) target union
170
+ // A target type union composed ONLY of the nav literals "/", ".", ".." (any
171
+ // order/subset) — the EARLY stub tree, before any real route exists.
172
+ /assignable to (?:parameter of )?type '(?:"\/"|"\."|"\.\.")(?:\s*\|\s*(?:"\/"|"\."|"\.\."))*'/,
173
+ // The same `<Link to>` / navigate target-union error AFTER real routes exist: the
174
+ // union ALWAYS still contains the ".." nav literal — which a normal string-literal
175
+ // union never does — so an "assignable to … type '…\"..\"…'" error is a route
176
+ // phantom REGARDLESS of which real routes are also in the union. (The nav-only
177
+ // pattern above STOPPED matching once the union grew, so the model got nagged to
178
+ // "fix" forward-referenced links (e.g. to="/x/create" written before x.create.tsx
179
+ // lands + routeTree regenerates) that the build resolves on its own — ~1/3 of a
180
+ // multi-route build burned chasing them. The real gate rebuilds the tree and still
181
+ // catches a genuinely missing route.)
182
+ /assignable to (?:parameter of )?type '[^']*"\.\."[^']*'/,
183
+ // `Route.useParams()` against the stub gives EMPTY params (`{}` or `never`), so a
184
+ // `route.params.<name>` access fails. ≥2 builds (night2 `never` ×84, twitter `{}` ×6).
185
+ // Broadest signal here — a real `{}`/`never` property access would also match — but
186
+ // those are rare and the GATE (real tree) still catches any genuine one.
187
+ /Property '[^']+' does not exist on type '(?:\{\}|never)'/,
188
+ ];
189
+
190
+ /** A TanStack route-API type error seen at write/interim time against the stub tree —
191
+ * a phantom the build erases; never surface it (the gate validates routes for real). */
192
+ export function isPhantomRouteError(message: string): boolean {
193
+ return ROUTE_API_SIGNATURES.some((re) => re.test(message));
194
+ }
195
+
196
+ /** A diagnostic that's expected mid-build noise (missing sibling module, or the
197
+ * stub-route-tree phantom) — not a real mistake the model should chase. */
198
+ function isTransientDiag(d: { code: number; message: string }): boolean {
199
+ return TRANSIENT_DIAG_CODES.has(d.code) || isPhantomRouteError(d.message);
200
+ }
201
+
202
+ /** Max diagnostics surfaced per write — keep the in-band feedback tight. */
203
+ const MAX_WRITE_GUARD_DIAGS = 5;
204
+
205
+ /** Render the per-issue lines (type errors + lint problems), capped + ordered. */
206
+ function writeGuardLines(
207
+ absPath: string,
208
+ typeErrors: readonly ITsDiagnostic[],
209
+ lintProblems: readonly IFileLintProblem[]
210
+ ): string {
211
+ let text = "";
212
+
213
+ try {
214
+ text = readFileSync(absPath, "utf8");
215
+ } catch {
216
+ text = "";
217
+ }
218
+
219
+ const lineOf = (offset: number): number =>
220
+ text.slice(0, offset).split("\n").length;
221
+ const typeLines = typeErrors.map(
222
+ (d) => ` L${String(lineOf(d.start))}: ${d.message} (TS${String(d.code)})`
223
+ );
224
+ const lintLines = lintProblems.map(
225
+ (p) => ` L${String(p.line)}: ${p.message} (${p.ruleId})`
226
+ );
227
+ const all = [...typeLines, ...lintLines];
228
+ const shown = all.slice(0, MAX_WRITE_GUARD_DIAGS).join("\n");
229
+ const more =
230
+ all.length > MAX_WRITE_GUARD_DIAGS
231
+ ? `\n …and ${String(all.length - MAX_WRITE_GUARD_DIAGS)} more`
232
+ : "";
233
+
234
+ return `${shown}${more}`;
235
+ }
236
+
237
+ /** Max dependant files (and errors each) to name in the blast-radius section. */
238
+ const MAX_DEP_FILES = 4;
239
+ const MAX_DEP_ERRORS = 2;
240
+
241
+ /**
242
+ * Render the CROSS-FILE blast radius: the dependant files an edit broke, with the
243
+ * first error(s) in each. Empty string when nothing downstream broke. This is the
244
+ * write-guard reaching across the import graph (see TsService.dependantErrors).
245
+ */
246
+ function dependantBlastRadius(
247
+ dependants: readonly { file: string; errors: readonly ITsDiagnostic[] }[]
248
+ ): string {
249
+ if (dependants.length === 0) {
250
+ return "";
251
+ }
252
+
253
+ const blocks = dependants.slice(0, MAX_DEP_FILES).map((d) => {
254
+ let text = "";
255
+
256
+ try {
257
+ text = readFileSync(d.file, "utf8");
258
+ } catch {
259
+ text = "";
260
+ }
261
+
262
+ const lineOf = (offset: number): number =>
263
+ text.slice(0, offset).split("\n").length;
264
+
265
+ return d.errors
266
+ .slice(0, MAX_DEP_ERRORS)
267
+ .map(
268
+ (e) =>
269
+ ` ${basename(d.file)}:${String(lineOf(e.start))} ${e.message} (TS${String(e.code)})`
270
+ )
271
+ .join("\n");
272
+ });
273
+ const more =
274
+ dependants.length > MAX_DEP_FILES
275
+ ? `\n …and ${String(dependants.length - MAX_DEP_FILES)} more file(s)`
276
+ : "";
277
+
278
+ return (
279
+ `\n\n⚠ BLAST RADIUS — this change broke ${String(dependants.length)} file(s) that ` +
280
+ `depend on it. Fix them too, or revert the change (e.g. a signature you altered):\n` +
281
+ `${blocks.join("\n")}${more}`
282
+ );
283
+ }
284
+
285
+ /**
286
+ * WRITE-TIME GUARD: the moment the model writes a file, check JUST that file and
287
+ * hand any real problems back AS THE TOOL RESULT — so the model fixes them THIS
288
+ * turn, while the file is fresh, before building more on a broken foundation. Two
289
+ * layers: (1) tsc diagnostics via the in-process language service (real type
290
+ * errors); (2) the gate's eslint rules on this one file (when `lintFile` is wired)
291
+ * — the STRICTNESS MOAT (`no-as`, `I`-prefix, `prefer-template`) that tsc is blind
292
+ * to. A run log showed `Object.keys(x) as unknown as …` written in every domain
293
+ * file: type-valid, so the old type-only guard passed it, and the `as` violations
294
+ * piled up unseen until the gate. Together they convert the dominant failure mode
295
+ * (write 8 files → discover a 40-issue cascade at the gate → many repair turns)
296
+ * into (write 1 file → see its issue now → fix it). Transient "cannot find module"
297
+ * for not-yet-created siblings is filtered as expected noise.
298
+ */
299
+ async function writeGuard(
300
+ ctx: { tsService: TsService; cwd: string; lintFile?: FileLinter },
301
+ file: string,
302
+ report: Reporter,
303
+ taskId: string
304
+ ): Promise<string> {
305
+ const { tsService, cwd, lintFile } = ctx;
306
+ // `file` is workspace-relative (the create/edit handler normalized it); the
307
+ // strip/linter/readFileSync need the absolute path.
308
+ const absPath = join(cwd, file);
309
+ // Strip the model's reflexive needless literal-to-union casts NOW (deterministic,
310
+ // safe) so it's never told about them and never spends a turn removing them.
311
+ const stripped = await stripLiteralCasts(absPath).catch(() => 0);
312
+
313
+ if (stripped > 0) {
314
+ report({
315
+ kind: "tool",
316
+ task: taskId,
317
+ message: `stripped ${String(stripped)} needless literal cast(s) in ${basename(absPath)}`,
318
+ });
319
+ }
320
+
321
+ tsService.refresh(file);
322
+
323
+ const typeErrors = tsService
324
+ .diagnostics(file)
325
+ .filter((d) => !isTransientDiag(d));
326
+ const lintProblems = lintFile === undefined ? [] : await lintFile(absPath);
327
+ // BLAST RADIUS: files that depend on this one and now have errors. Computed even
328
+ // when THIS file is clean — a signature change can compile here but break callers.
329
+ // Drop the stub-route phantom here too (the build regenerates the tree).
330
+ const dependants = tsService
331
+ .dependantErrors(file, TRANSIENT_DIAG_CODES)
332
+ .map((d) => ({
333
+ file: d.file,
334
+ errors: d.errors.filter((e) => !isPhantomRouteError(e.message)),
335
+ }))
336
+ .filter((d) => d.errors.length > 0);
337
+ const total = typeErrors.length + lintProblems.length;
338
+
339
+ if (total === 0 && dependants.length === 0) {
340
+ return "";
341
+ }
342
+
343
+ const detail = writeGuardLines(absPath, typeErrors, lintProblems);
344
+ const blast = dependantBlastRadius(dependants);
345
+ const depNote =
346
+ dependants.length > 0
347
+ ? `, ${String(dependants.length)} dependant file(s) broken`
348
+ : "";
349
+
350
+ // Surface the ACTUAL errors (codes + messages) into the event — not just a count —
351
+ // so the log shows WHAT failed (the corrective feedback also rides the tool result).
352
+ // A log without the real errors can't tell us which mistakes are systematic.
353
+ report({
354
+ kind: "tool",
355
+ task: taskId,
356
+ message: `⚠ write-check: ${String(typeErrors.length)} type + ${String(lintProblems.length)} lint issue(s)${depNote} in ${basename(absPath)}:\n${detail}${blast}`,
357
+ });
358
+
359
+ if (total === 0) {
360
+ return blast;
361
+ }
362
+
363
+ return (
364
+ `\n\n⚠ CHECK of this file found ${String(total)} issue(s) — fix them now ` +
365
+ "(edit this file) before writing others; ignore any 'cannot find module' for " +
366
+ `files you'll create next:\n${detail}${blast}`
367
+ );
368
+ }
369
+
370
+ /**
371
+ * Invoke the write-guard for a just-written file — best-effort: a guard failure
372
+ * must NEVER break the build (the gate stays the authority), so a null service or
373
+ * any thrown error degrades to no feedback. Extracted so `runToolCalls` stays
374
+ * under the cognitive-complexity bar.
375
+ */
376
+ async function runWriteGuard(ctx: ILoopCtx, path: string): Promise<string> {
377
+ if (
378
+ ctx.tsService === null ||
379
+ path.length === 0 ||
380
+ !flags.lspWriteFeedback()
381
+ ) {
382
+ return "";
383
+ }
384
+
385
+ try {
386
+ return await writeGuard(
387
+ {
388
+ tsService: ctx.tsService,
389
+ cwd: ctx.cwd,
390
+ ...(ctx.lintFile === undefined ? {} : { lintFile: ctx.lintFile }),
391
+ },
392
+ path,
393
+ ctx.report,
394
+ ctx.task.id
395
+ );
396
+ } catch {
397
+ return "";
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Run the model's tool calls: execute each, feed the result back, and report
403
+ * whether any touched an editable file (which means we should re-gate). Mutates
404
+ * `state.edits`. The semantic WRITE tools (rename/organize) also touch disk.
405
+ */
406
+ export async function runToolCalls(
407
+ toolCalls: readonly IToolCall[],
408
+ ctx: ILoopCtx,
409
+ state: ILoopState
410
+ ): Promise<boolean> {
411
+ let touchedEditable = false;
412
+
413
+ for (let i = 0; i < toolCalls.length; i += 1) {
414
+ const call = toolCalls[i];
415
+
416
+ if (call === undefined) {
417
+ continue;
418
+ }
419
+
420
+ // Count an edit/create ONLY when it actually wrote an in-scope file. We read
421
+ // this from the handler's `edit`/`create` event — which carries the path it
422
+ // ACTUALLY wrote, already normalized (absolute / repeated-root / backslash
423
+ // paths resolved). Scope-checking the raw tool arg here instead would miss a
424
+ // write the handler normalized into scope, skipping the gate. The event fires
425
+ // only on a successful write, so failures/rejects never count. See P1/P2.
426
+ // (Object ref, not a captured `let`: CFA de-narrows a property after a call.)
427
+ const wrote = { value: false, path: "" };
428
+
429
+ const report: Reporter = (event) => {
430
+ if (
431
+ (event.kind === "edit" || event.kind === "create") &&
432
+ event.file !== undefined &&
433
+ isInScope(event.file, ctx.task.files)
434
+ ) {
435
+ wrote.value = true;
436
+ wrote.path = event.file;
437
+ }
438
+
439
+ ctx.report(event);
440
+ };
441
+
442
+ const result = await executeTool(call, {
443
+ cwd: ctx.cwd,
444
+ files: ctx.task.files,
445
+ report,
446
+ task: ctx.task.id,
447
+ tsService: ctx.tsService,
448
+ ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
449
+ ...(ctx.setupWeb === undefined ? {} : { setupWeb: ctx.setupWeb }),
450
+ ...(ctx.readOnly === undefined ? {} : { readOnly: ctx.readOnly }),
451
+ });
452
+
453
+ let feedback = "";
454
+
455
+ if (wrote.value) {
456
+ touchedEditable = true;
457
+ state.edits += 1;
458
+ feedback = await runWriteGuard(ctx, wrote.path);
459
+ }
460
+
461
+ // A semantic write (rename/organize_imports) is scope-enforced internally and
462
+ // mutates on success — re-gate to confirm. (These don't feed state.edits.)
463
+ if (
464
+ call.name === TOOL_NAME.renameSymbol ||
465
+ call.name === TOOL_NAME.organizeImports
466
+ ) {
467
+ touchedEditable = true;
468
+ }
469
+
470
+ ctx.messages.push({
471
+ role: "tool",
472
+ content: `${result}${feedback}`,
473
+ toolCallId: call.id ?? `call_${i}`,
474
+ });
475
+ }
476
+
477
+ return touchedEditable;
478
+ }
479
+
480
+ /**
481
+ * Deterministic auto-fixes applied before the gate — mechanical fixes the model
482
+ * shouldn't burn turns on. TypeScript's own safe quick-fixes (missing imports,
483
+ * unused) + ast-grep SAFE idiom rewrites (`new Array(n).fill` → `Array.from`).
484
+ * The `tsc -p` gate re-validates, so a bad fix can't ship; never throws.
485
+ */
486
+ async function applyDeterministicFixes(ctx: ILoopCtx): Promise<void> {
487
+ const { task, cwd, tsService, report } = ctx;
488
+ // Resolve globs to concrete files — iterating task.files literally would skip a
489
+ // glob scope like `["**/*"]` (the common interactive default), so the fixes
490
+ // never ran there. See P1 review.
491
+ const files = await resolveScopeFiles(cwd, task.files);
492
+
493
+ if (tsService !== null) {
494
+ let tsFixed = 0;
495
+
496
+ for (const f of files) {
497
+ try {
498
+ if (await fileExists(cwd, f)) {
499
+ tsService.refresh(f);
500
+ tsFixed += tsService.fixAll(f);
501
+ // Dedupe/sort imports + drop unused ones the model left behind — free
502
+ // mechanical cleanup so it never spends a repair turn on import hygiene.
503
+ tsFixed += tsService.organizeImports(f);
504
+ }
505
+ } catch {
506
+ // degrade silently — the gate still runs below
507
+ }
508
+ }
509
+
510
+ if (tsFixed > 0) {
511
+ report({
512
+ kind: "tool",
513
+ task: task.id,
514
+ message: `tsFixAll: applied ${tsFixed} TypeScript quick-fix(es)`,
515
+ });
516
+ }
517
+ }
518
+
519
+ if (flags.noAstgrep()) {
520
+ return;
521
+ }
522
+
523
+ let astFixed = 0;
524
+
525
+ for (const f of files) {
526
+ try {
527
+ if (await fileExists(cwd, f)) {
528
+ astFixed += await astGrepFix(join(cwd, f));
529
+ // Backstop the write-time strip (covers files changed via rename/organize
530
+ // or any path that skipped the write-guard).
531
+ astFixed += await stripLiteralCasts(join(cwd, f));
532
+ }
533
+ } catch {
534
+ // degrade silently — gate is the authority
535
+ }
536
+ }
537
+
538
+ if (astFixed > 0) {
539
+ report({
540
+ kind: "tool",
541
+ task: task.id,
542
+ message: `astGrepFix: applied ${astFixed} idiom rewrite(s)`,
543
+ });
544
+ }
545
+ }
546
+
547
+ /**
548
+ * On a GREEN task, strip the redundant `const` annotations no stock lint rule
549
+ * catches (over-annotation of call/expression-initialized locals) — then re-gate
550
+ * and REVERT the whole file if anything regressed. Verified-safe: the structural
551
+ * rewrite only sticks when the full gate (incl. prettier --check) stays green,
552
+ * so a drop that changed an inferred type can never ship. Runs once, on the turn
553
+ * the task goes green; a no-op when ast-grep is off or nothing is redundant.
554
+ */
555
+ async function polishOnGreen(ctx: ILoopCtx): Promise<void> {
556
+ const { task, cwd, parse, report } = ctx;
557
+
558
+ if (flags.noAstgrep()) {
559
+ return;
560
+ }
561
+
562
+ // Resolve globs so a glob scope is polished too (not silently skipped).
563
+ const files = await resolveScopeFiles(cwd, task.files);
564
+ const snapshot = new Map<string, string>();
565
+
566
+ for (const f of files) {
567
+ if (await fileExists(cwd, f)) {
568
+ snapshot.set(f, await Bun.file(join(cwd, f)).text());
569
+ }
570
+ }
571
+
572
+ let dropped = 0;
573
+
574
+ for (const f of files) {
575
+ if (await fileExists(cwd, f)) {
576
+ try {
577
+ dropped += await dropRedundantAnnotations(join(cwd, f));
578
+ } catch {
579
+ // degrade silently — we revalidate and revert below
580
+ }
581
+ }
582
+ }
583
+
584
+ if (dropped === 0) {
585
+ return;
586
+ }
587
+
588
+ // Re-format (the drop strips trailing semicolons) before re-gating.
589
+ if (task.fix !== undefined && task.fix.length > 0) {
590
+ await runAccept(
591
+ { ...task, accept: task.fix },
592
+ cwd,
593
+ ctx.signal === undefined ? {} : { signal: ctx.signal }
594
+ );
595
+ }
596
+
597
+ const recheck = await validate(
598
+ task,
599
+ cwd,
600
+ parse,
601
+ ctx.signal === undefined ? {} : { signal: ctx.signal }
602
+ );
603
+
604
+ if (recheck.passed) {
605
+ report({
606
+ kind: "tool",
607
+ task: task.id,
608
+ message: `polish: dropped ${dropped} redundant annotation(s)`,
609
+ });
610
+
611
+ return;
612
+ }
613
+
614
+ // A drop changed an inferred type — roll the whole file set back to green.
615
+ for (const [f, content] of snapshot) {
616
+ await Bun.write(join(cwd, f), content);
617
+ }
618
+ }
619
+
620
+ /** Snapshot the editable files' mtimes (ms) — cheap stat, used to detect which
621
+ * files the deterministic fixers + fix command rewrote. */
622
+ async function snapshotMtimes(
623
+ cwd: string,
624
+ files: string[]
625
+ ): Promise<Map<string, number>> {
626
+ const out = new Map<string, number>();
627
+
628
+ for (const f of await resolveScopeFiles(cwd, files)) {
629
+ try {
630
+ out.set(f, Bun.file(join(cwd, f)).lastModified);
631
+ } catch {
632
+ // ignore — a file that can't be stat'd just isn't tracked
633
+ }
634
+ }
635
+
636
+ return out;
637
+ }
638
+
639
+ /** Files whose mtime advanced between two snapshots — i.e. a fixer rewrote them. */
640
+ function changedSince(
641
+ before: Map<string, number>,
642
+ after: Map<string, number>
643
+ ): string[] {
644
+ const changed: string[] = [];
645
+
646
+ for (const [f, mtime] of after) {
647
+ const prev = before.get(f);
648
+
649
+ if (prev === undefined || mtime > prev) {
650
+ changed.push(f);
651
+ }
652
+ }
653
+
654
+ return changed;
655
+ }
656
+
657
+ /** Max auto-fixed files to name in the notice before eliding. */
658
+ const MAX_AUTOFIX_NAMED = 20;
659
+
660
+ /** Tell the model what the janitor just changed, so it re-reads before editing and
661
+ * doesn't waste turns re-fixing formatting/imports (or edit stale text → reject). */
662
+ function autoFixNotice(files: string[]): string {
663
+ const shown = files.slice(0, MAX_AUTOFIX_NAMED).join(", ");
664
+ const more =
665
+ files.length > MAX_AUTOFIX_NAMED
666
+ ? ` (+${String(files.length - MAX_AUTOFIX_NAMED)} more)`
667
+ : "";
668
+
669
+ return (
670
+ `NOTE: automatic fixers (prettier, eslint --fix, organize-imports, TS quick-fixes) ` +
671
+ `just reformatted/fixed and SAVED these files: ${shown}${more}. Those style/import/` +
672
+ `formatting fixes are DONE — do not redo them. Their on-disk text now DIFFERS from ` +
673
+ `what you wrote, so \`read\` a file before editing it. Fix ONLY the errors below.`
674
+ );
675
+ }
676
+
677
+ /**
678
+ * The deterministic gate — the only authority on "done". Auto-fix, run the
679
+ * optional fix command, validate, and return a terminal result (done/stuck) or
680
+ * null to keep going (having fed the failures back into the conversation).
681
+ */
682
+ export async function settleGate(
683
+ ctx: ILoopCtx,
684
+ state: ILoopState,
685
+ turn: number
686
+ ): Promise<IRunResult | null> {
687
+ const { task, cwd, parse, report, messages } = ctx;
688
+ // Snapshot before the fixers so we can tell the model exactly what they changed
689
+ // (else it re-fixes already-fixed style and edits now-stale text → rejects).
690
+ const beforeFix = await snapshotMtimes(cwd, task.files);
691
+
692
+ await applyDeterministicFixes(ctx);
693
+
694
+ if (task.fix !== undefined && task.fix.length > 0) {
695
+ await runAccept(
696
+ { ...task, accept: task.fix },
697
+ cwd,
698
+ ctx.signal === undefined ? {} : { signal: ctx.signal }
699
+ );
700
+ }
701
+
702
+ const autoFixed = changedSince(
703
+ beforeFix,
704
+ await snapshotMtimes(cwd, task.files)
705
+ );
706
+
707
+ if (autoFixed.length > 0) {
708
+ report({
709
+ kind: "tool",
710
+ task: task.id,
711
+ message: `auto-fixed ${String(autoFixed.length)} file(s) (prettier/eslint/imports) — noted to the model`,
712
+ });
713
+ }
714
+
715
+ if (ctx.onGateChunk !== undefined) {
716
+ report({
717
+ kind: "tool",
718
+ task: task.id,
719
+ message: `⚙ running gate · turn ${turn}…`,
720
+ });
721
+ }
722
+
723
+ const gate = await validate(task, cwd, parse, {
724
+ ...(ctx.onGateChunk === undefined ? {} : { onChunk: ctx.onGateChunk }),
725
+ ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
726
+ });
727
+
728
+ // Run meta-rules against the project — project structure invariants the gate
729
+ // can't express. Convert error-severity violations to gate failures; warn
730
+ // violations are surfaced in feedback but don't block. Apply config overrides
731
+ // from ctx.ruleOverrides (already loaded and normalized in run.ts).
732
+ let metaViolations: IMetaRuleViolation[] = [];
733
+
734
+ try {
735
+ const metaContext = buildMetaRuleContext(
736
+ cwd,
737
+ ctx.stackProfile?.packs ?? []
738
+ );
739
+
740
+ metaViolations = runMetaRules(META_RULES, metaContext, ctx.ruleOverrides);
741
+ } catch {
742
+ // Degrade silently — meta-rules are supplementary to the gate
743
+ }
744
+
745
+ const metaErrors = metaViolations.filter((v) => v.severity === "error");
746
+ const gateErrors = gate.errors.concat(
747
+ metaErrors.map((v) => ({
748
+ key: `${v.file}:${v.ruleId}`,
749
+ file: v.file,
750
+ rule: v.ruleId,
751
+ message: v.message,
752
+ }))
753
+ );
754
+
755
+ if (state.lastGateCount >= 0 && gateErrors.length > state.lastGateCount) {
756
+ state.regressions += 1;
757
+ }
758
+
759
+ state.lastGateCount = gateErrors.length;
760
+
761
+ // Determine pass/fail: the gate passes only if BOTH gate command AND meta-rules are clean
762
+ const gatePassed = gate.passed && metaErrors.length === 0;
763
+
764
+ // On red, surface the ACTUAL errors (codes + messages) into the event — so the
765
+ // log records WHAT failed at the gate, not just a count (the analysis substrate
766
+ // for finding systematic mistakes to fix in the harness).
767
+ const gateDetail = gatePassed
768
+ ? ""
769
+ : `:\n${gateErrors
770
+ .slice(0, 20)
771
+ .map((e) => ` ${e.message}`)
772
+ .join("\n")}`;
773
+
774
+ report({
775
+ kind: "validated",
776
+ task: task.id,
777
+ cycle: turn,
778
+ passed: gatePassed,
779
+ errors: gateErrors.length,
780
+ message: gatePassed
781
+ ? `task ${task.id} · turn ${turn}: GREEN`
782
+ : `task ${task.id} · turn ${turn}: red (${String(gateErrors.length)} error(s))${gateDetail}`,
783
+ });
784
+
785
+ if (gatePassed) {
786
+ await polishOnGreen(ctx);
787
+
788
+ report({
789
+ kind: "done",
790
+ task: task.id,
791
+ cycles: turn,
792
+ message: `task ${task.id}: done in ${turn} turn(s)`,
793
+ });
794
+
795
+ return {
796
+ task: task.id,
797
+ redConfirmed: true,
798
+ status: RUN_STATUS.done,
799
+ cycles: turn,
800
+ };
801
+ }
802
+
803
+ state.gateNoProgress = sameErrorSet(state.prevGateErrors, gateErrors)
804
+ ? state.gateNoProgress + 1
805
+ : 0;
806
+ state.prevGateErrors = gateErrors;
807
+
808
+ if (state.gateNoProgress >= LOOP_LIMITS.gateStuckRepeats) {
809
+ report({
810
+ kind: "stuck",
811
+ task: task.id,
812
+ cycles: turn,
813
+ message: `task ${task.id}: stuck (gate unchanged ${LOOP_LIMITS.gateStuckRepeats}x)`,
814
+ });
815
+
816
+ return {
817
+ task: task.id,
818
+ redConfirmed: true,
819
+ status: RUN_STATUS.stuck,
820
+ cycles: turn,
821
+ reason: STUCK_REASON.stalled,
822
+ };
823
+ }
824
+
825
+ const feedback = await gateFeedback(gateErrors, task, cwd, metaViolations);
826
+ const notice = autoFixed.length > 0 ? `${autoFixNotice(autoFixed)}\n\n` : "";
827
+
828
+ messages.push({ role: "user", content: `${notice}${feedback}` });
829
+
830
+ return null;
831
+ }
832
+
833
+ /** Report how long a turn took (and cumulative). */
834
+ export function emitTiming(
835
+ report: Reporter,
836
+ task: string,
837
+ turn: number,
838
+ turnStart: number,
839
+ taskStart: number
840
+ ): void {
841
+ const turnMs = Math.round(performance.now() - turnStart);
842
+ const totalMs = Math.round(performance.now() - taskStart);
843
+
844
+ report({
845
+ kind: "timing",
846
+ task,
847
+ cycle: turn,
848
+ ms: turnMs,
849
+ message: `turn ${turn} took ${secs(turnMs)} (total ${secs(totalMs)})`,
850
+ });
851
+ }
852
+
853
+ /** Human-readable duration: ms under a second, else seconds with one decimal. */
854
+ function secs(ms: number): string {
855
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
856
+ }