@agjs/tsforge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tsforge.js +2 -0
- package/package.json +35 -0
- package/src/agent/agent.constants.ts +382 -0
- package/src/agent/agent.types.ts +34 -0
- package/src/agent/index.ts +4 -0
- package/src/agent/model-agent.ts +297 -0
- package/src/agent/tool-repair.ts +194 -0
- package/src/agent/tools.ts +190 -0
- package/src/browser/checks.ts +96 -0
- package/src/browser/index.ts +8 -0
- package/src/browser/oracle.ts +303 -0
- package/src/classify.ts +48 -0
- package/src/cli.ts +1333 -0
- package/src/config/config.constants.ts +9 -0
- package/src/config/flags.ts +32 -0
- package/src/config/index.ts +8 -0
- package/src/config/tsforge-config.ts +301 -0
- package/src/constitution/baseline.ts +257 -0
- package/src/detect-gate.ts +498 -0
- package/src/eval/eval.types.ts +36 -0
- package/src/eval/index.ts +3 -0
- package/src/eval/judge.ts +62 -0
- package/src/eval/score.ts +39 -0
- package/src/files/create.ts +22 -0
- package/src/files/edit.ts +193 -0
- package/src/files/files.constants.ts +11 -0
- package/src/files/files.types.ts +81 -0
- package/src/files/hashline-format.ts +110 -0
- package/src/files/hashline.ts +689 -0
- package/src/files/index.ts +19 -0
- package/src/index.ts +8 -0
- package/src/inference/index.ts +6 -0
- package/src/inference/inference.constants.ts +34 -0
- package/src/inference/inference.types.ts +123 -0
- package/src/inference/openai-compatible.ts +113 -0
- package/src/inference/stream-guard.ts +161 -0
- package/src/inference/stream.ts +370 -0
- package/src/inference/transport.ts +78 -0
- package/src/inference/wire.ts +0 -0
- package/src/lib/fs/fs.ts +126 -0
- package/src/lib/fs/fs.types.ts +5 -0
- package/src/lib/fs/index.ts +3 -0
- package/src/lib/fs/process.ts +146 -0
- package/src/lib/guards/guards.ts +9 -0
- package/src/lib/guards/index.ts +1 -0
- package/src/lib/json/index.ts +1 -0
- package/src/lib/json/json.ts +12 -0
- package/src/lib/scope/index.ts +2 -0
- package/src/lib/scope/scope.constants.ts +3 -0
- package/src/lib/scope/scope.ts +40 -0
- package/src/loop/astgrep-fix.ts +228 -0
- package/src/loop/feedback/feedback.ts +138 -0
- package/src/loop/feedback/index.ts +8 -0
- package/src/loop/feedback/meta-rule-docs.ts +41 -0
- package/src/loop/feedback/meta-rule-feedback.ts +61 -0
- package/src/loop/feedback/rule-docs.generated.json +112 -0
- package/src/loop/feedback/rule-docs.ts +342 -0
- package/src/loop/index.ts +19 -0
- package/src/loop/loop.constants.ts +68 -0
- package/src/loop/loop.types.ts +99 -0
- package/src/loop/prompt/index.ts +2 -0
- package/src/loop/prompt/project-map.ts +69 -0
- package/src/loop/prompt/prompt.ts +107 -0
- package/src/loop/quality.ts +174 -0
- package/src/loop/rule-docs.generated.json +367 -0
- package/src/loop/run-spec.ts +88 -0
- package/src/loop/run.ts +400 -0
- package/src/loop/session.ts +1410 -0
- package/src/loop/tools/add-dependency.ts +71 -0
- package/src/loop/tools/condense.ts +498 -0
- package/src/loop/tools/edit-hashline.ts +80 -0
- package/src/loop/tools/execute-tool.ts +80 -0
- package/src/loop/tools/file-ops.ts +323 -0
- package/src/loop/tools/index.ts +2 -0
- package/src/loop/tools/lsp-ops.ts +222 -0
- package/src/loop/tools/scaffold-routes.ts +68 -0
- package/src/loop/tools/scaffold-ui.ts +62 -0
- package/src/loop/tools/scaffold-web.ts +35 -0
- package/src/loop/tools/tool-context.ts +126 -0
- package/src/loop/ttsr-defaults.ts +53 -0
- package/src/loop/ttsr.ts +322 -0
- package/src/loop/turn.ts +856 -0
- package/src/lsp/index.ts +2 -0
- package/src/lsp/lsp.types.ts +56 -0
- package/src/lsp/service.ts +500 -0
- package/src/meta-rules/context.ts +195 -0
- package/src/meta-rules/index.ts +9 -0
- package/src/meta-rules/meta-rules.types.ts +47 -0
- package/src/meta-rules/parsers/package-json-parser.ts +51 -0
- package/src/meta-rules/registry.ts +37 -0
- package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
- package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
- package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
- package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
- package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
- package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
- package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
- package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
- package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
- package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
- package/src/meta-rules/runner.ts +64 -0
- package/src/models-config.ts +196 -0
- package/src/render/ansi.ts +289 -0
- package/src/render/banner.ts +113 -0
- package/src/render/box.ts +134 -0
- package/src/render/index.ts +7 -0
- package/src/render/markdown.ts +123 -0
- package/src/render/render.types.ts +21 -0
- package/src/render/stream-markdown.ts +128 -0
- package/src/render/style.ts +26 -0
- package/src/rule-packs/bullmq/index.ts +39 -0
- package/src/rule-packs/bullmq/rules/index.ts +7 -0
- package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
- package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
- package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
- package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
- package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
- package/src/rule-packs/bullmq/utils.ts +334 -0
- package/src/rule-packs/code-flow/index.ts +25 -0
- package/src/rule-packs/code-flow/rules/index.ts +3 -0
- package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
- package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
- package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
- package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
- package/src/rule-packs/comment-hygiene/index.ts +25 -0
- package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
- package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
- package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
- package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
- package/src/rule-packs/create-rule.ts +9 -0
- package/src/rule-packs/drizzle/index.ts +41 -0
- package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
- package/src/rule-packs/drizzle/rules/index.ts +8 -0
- package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
- package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
- package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
- package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
- package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
- package/src/rule-packs/drizzle/utils.ts +115 -0
- package/src/rule-packs/elysia/index.ts +43 -0
- package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
- package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
- package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
- package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
- package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
- package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
- package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
- package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
- package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
- package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
- package/src/rule-packs/env-access/index.ts +23 -0
- package/src/rule-packs/env-access/rules/index.ts +2 -0
- package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
- package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
- package/src/rule-packs/i18n-keys/index.ts +19 -0
- package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
- package/src/rule-packs/index.ts +139 -0
- package/src/rule-packs/jwt-cookies/index.ts +25 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
- package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
- package/src/rule-packs/jwt-cookies/utils.ts +188 -0
- package/src/rule-packs/oauth-security/index.ts +25 -0
- package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
- package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
- package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
- package/src/rule-packs/oauth-security/utils.ts +127 -0
- package/src/rule-packs/react-component-architecture/index.ts +35 -0
- package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
- package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
- package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
- package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
- package/src/rule-packs/react-component-architecture/utils.ts +47 -0
- package/src/rule-packs/rule-packs.types.ts +18 -0
- package/src/rule-packs/structured-logging/index.ts +26 -0
- package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
- package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
- package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
- package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
- package/src/rule-packs/tanstack-query/index.ts +20 -0
- package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
- package/src/rule-packs/test-conventions/index.ts +23 -0
- package/src/rule-packs/test-conventions/rules/index.ts +2 -0
- package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
- package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
- package/src/rule-packs/utils.ts +142 -0
- package/src/session-store.ts +359 -0
- package/src/spec/generate-tests.ts +213 -0
- package/src/spec/index.ts +5 -0
- package/src/spec/parse.ts +152 -0
- package/src/spec/review-tests.ts +162 -0
- package/src/spec/spec.constants.ts +13 -0
- package/src/spec/spec.types.ts +79 -0
- package/src/stack-detection/detect.ts +246 -0
- package/src/stack-detection/index.ts +3 -0
- package/src/stack-detection/packs.ts +174 -0
- package/src/stack-detection/stack-detection.types.ts +47 -0
- package/src/validate/accept.ts +49 -0
- package/src/validate/errors.ts +35 -0
- package/src/validate/index.ts +12 -0
- package/src/validate/parse.ts +148 -0
- package/src/validate/run-tests.ts +59 -0
- package/src/validate/validate.ts +40 -0
- package/src/validate/validate.types.ts +52 -0
- package/src/web-components.ts +638 -0
- package/src/web-coverage.ts +89 -0
- package/src/web-routes.ts +151 -0
- package/src/web-templates.ts +1011 -0
- package/strict.eslint.config.mjs +84 -0
- package/strict.web.eslint.config.mjs +185 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { writable, normalizeWorkspacePath } from "../../lib/scope";
|
|
3
|
+
import {
|
|
4
|
+
materializeComponents,
|
|
5
|
+
asThemeName,
|
|
6
|
+
asComponentNames,
|
|
7
|
+
COMPONENT_NAMES,
|
|
8
|
+
THEME_NAMES,
|
|
9
|
+
} from "../../web-components";
|
|
10
|
+
import { type IToolContext } from "./tool-context";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `scaffold_ui` — materialize tested, THEMED UI primitives so the model never
|
|
14
|
+
* authors (or re-authors) a button/card/input/etc. Writes `src/index.css` (the
|
|
15
|
+
* vibe's design-token block) + the requested `src/components/ui/*.tsx` primitives
|
|
16
|
+
* with the theme's per-component classes baked in. Overwrites (re-theming is
|
|
17
|
+
* idempotent). Reports ONE summary event — deliberately NOT per-file `create`
|
|
18
|
+
* events, so the write-guard doesn't re-check known-good vendored files.
|
|
19
|
+
*/
|
|
20
|
+
export async function doScaffoldUi(
|
|
21
|
+
args: Record<string, unknown>,
|
|
22
|
+
ctx: IToolContext
|
|
23
|
+
): Promise<string> {
|
|
24
|
+
const theme = asThemeName(args.theme);
|
|
25
|
+
|
|
26
|
+
if (theme === undefined) {
|
|
27
|
+
return `scaffold_ui REJECTED: \`theme\` must be one of: ${THEME_NAMES.join(", ")}.`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const components = asComponentNames(args.components);
|
|
31
|
+
|
|
32
|
+
if (components.length === 0) {
|
|
33
|
+
return `scaffold_ui REJECTED: \`components\` must be a non-empty array from: ${COMPONENT_NAMES.join(", ")}.`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const files = materializeComponents(theme, components);
|
|
37
|
+
const written: string[] = [];
|
|
38
|
+
|
|
39
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
40
|
+
const path = normalizeWorkspacePath(ctx.cwd, rel);
|
|
41
|
+
|
|
42
|
+
if (!writable(path, ctx.files)) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await Bun.write(join(ctx.cwd, path), content);
|
|
47
|
+
written.push(path);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
ctx.report({
|
|
51
|
+
kind: "tool",
|
|
52
|
+
task: ctx.task,
|
|
53
|
+
message: `scaffold_ui: wrote ${String(written.length)} themed file(s) [${theme}] — ${components.join(", ")}`,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
`scaffold_ui: wrote ${String(written.length)} file(s) with the "${theme}" theme ` +
|
|
58
|
+
`(${written.join(", ")}). Import these from @/components/ui (e.g. \`import { Button } ` +
|
|
59
|
+
`from "@/components/ui/button"\`) and COMPOSE them — do NOT re-create any primitive ` +
|
|
60
|
+
`or edit the files under src/components/ui.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { str, type IToolContext } from "./tool-context";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `scaffold_web` — the AGENT's decision to turn this workspace into a from-scratch
|
|
5
|
+
* web app. It calls this ONLY when the request is "build a web app/UI"; for a
|
|
6
|
+
* question, a CLI script, or editing existing code it just does the work. This
|
|
7
|
+
* replaces the old up-front classifier (which mis-fired — e.g. "render a table in
|
|
8
|
+
* the CLI" was scaffolded as a Vite app). The host (interactive CLI) supplies
|
|
9
|
+
* `ctx.setupWeb`, which scaffolds the stack + deps and switches the session to the
|
|
10
|
+
* web gate/guidance; here we just invoke it and tell the model how to proceed.
|
|
11
|
+
*/
|
|
12
|
+
export async function doScaffoldWeb(
|
|
13
|
+
args: Record<string, unknown>,
|
|
14
|
+
ctx: IToolContext
|
|
15
|
+
): Promise<string> {
|
|
16
|
+
if (ctx.setupWeb === undefined) {
|
|
17
|
+
return (
|
|
18
|
+
"scaffold_web is unavailable here — this workspace isn't set up for " +
|
|
19
|
+
"interactive scaffolding. Build directly against the existing project."
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const requested = str(args, "framework").toLowerCase();
|
|
24
|
+
const framework = requested === "vanilla" ? "vanilla" : "react";
|
|
25
|
+
|
|
26
|
+
await ctx.setupWeb(framework);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
`Scaffolded a ${framework} project (stack + deps installed) and switched to ` +
|
|
30
|
+
"the web gate. Now BUILD it: first write the type contract — each domain's " +
|
|
31
|
+
"`src/<domain>/<domain>.types.ts` (+ `.constants.ts`) — then implement the " +
|
|
32
|
+
"routes/features against those types using @/components/ui. Run the gate when " +
|
|
33
|
+
"done; it confirms the build, types, lint, and a browser render."
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { repairArgs } from "../../agent/tool-repair";
|
|
2
|
+
import type { TsService } from "../../lsp";
|
|
3
|
+
import type { Reporter } from "../loop.types";
|
|
4
|
+
import type { SessionSnapshotStore } from "../../files/hashline";
|
|
5
|
+
|
|
6
|
+
export interface IToolContext {
|
|
7
|
+
cwd: string;
|
|
8
|
+
/** Editable scope — `edit`/`create` outside it are rejected. */
|
|
9
|
+
files: string[];
|
|
10
|
+
report: Reporter;
|
|
11
|
+
task: string;
|
|
12
|
+
/** In-process TypeScript LanguageService — backs the semantic tools
|
|
13
|
+
* (rename/type_at/find_references/symbol_search/diagnostics/organize_imports).
|
|
14
|
+
* Null when the project has no tsconfig. */
|
|
15
|
+
tsService?: TsService | null;
|
|
16
|
+
/** Cancellation for the in-flight turn — passed to the `run` tool (and search)
|
|
17
|
+
* so a model-issued command is killed on Ctrl-C, not left running. */
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
/** Turn this workspace into a web project: scaffold the stack + deps and switch
|
|
20
|
+
* the session to the web gate/guidance. Wired by the interactive CLI so the
|
|
21
|
+
* AGENT decides whether to scaffold (via the `scaffold_web` tool) instead of a
|
|
22
|
+
* brittle up-front classifier. Absent where unsupported (headless already
|
|
23
|
+
* scaffolds up front), in which case the tool reports it's unavailable. */
|
|
24
|
+
setupWeb?: (framework: string) => Promise<void>;
|
|
25
|
+
/** PLAN MODE: mutating tools are rejected at dispatch and `run` only accepts
|
|
26
|
+
* read-only commands — the hard guarantee behind the filtered tool list (a
|
|
27
|
+
* salvaged/forced call could otherwise still write). */
|
|
28
|
+
readOnly?: boolean;
|
|
29
|
+
/** Hashline snapshot store for stale-anchor recovery (per-session, lazily initialized). */
|
|
30
|
+
snapshotStore?: SessionSnapshotStore;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A required string arg, or "" if missing/wrong-type. */
|
|
34
|
+
export function str(args: Record<string, unknown>, key: string): string {
|
|
35
|
+
const v = args[key];
|
|
36
|
+
|
|
37
|
+
return typeof v === "string" ? v : "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface IParseResult<T> {
|
|
41
|
+
value: T | null;
|
|
42
|
+
feedback?: string; // L3 feedback when recoverable=false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse a tool's args, with VALIDATE-THEN-REPAIR: try the tool's own parser; if
|
|
47
|
+
* it rejects, apply the repair ladder (L0→L1→L2→L3). Emits telemetry:
|
|
48
|
+
* - `repair:L0:<rule>` / `repair:L1:<rule>` / `repair:L2:<rule>` per applied rule
|
|
49
|
+
* - `repair:L3` when re-asking (feedback included in result)
|
|
50
|
+
* - `tool_input_rejected:<tool>` when (rarely) no parse succeeded
|
|
51
|
+
* Returns both the parsed value and optional L3 feedback to surface to the model.
|
|
52
|
+
*/
|
|
53
|
+
export function parseOrRepair<T>(
|
|
54
|
+
raw: Record<string, unknown>,
|
|
55
|
+
normalize: (a: Record<string, unknown>) => T | null,
|
|
56
|
+
ctx: IToolContext,
|
|
57
|
+
tool: string
|
|
58
|
+
): IParseResult<T> {
|
|
59
|
+
const direct = normalize(raw);
|
|
60
|
+
|
|
61
|
+
if (direct !== null) {
|
|
62
|
+
return { value: direct };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const repair = repairArgs(raw);
|
|
66
|
+
|
|
67
|
+
if (repair.applied.length > 0) {
|
|
68
|
+
for (const rule of repair.applied) {
|
|
69
|
+
ctx.report({
|
|
70
|
+
kind: "repair",
|
|
71
|
+
task: ctx.task,
|
|
72
|
+
message: `${tool}:${rule}`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const repaired = repair.applied.length > 0 ? normalize(repair.args) : null;
|
|
78
|
+
|
|
79
|
+
if (repaired !== null) {
|
|
80
|
+
ctx.report({
|
|
81
|
+
kind: "tool",
|
|
82
|
+
task: ctx.task,
|
|
83
|
+
message: `tool_input_repaired:${tool} (${repair.applied.join(", ")})`,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return { value: repaired };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// L3: If still broken after L0-L2, return feedback if provided (recoverable=false).
|
|
90
|
+
if (
|
|
91
|
+
!repair.recoverable &&
|
|
92
|
+
repair.feedback !== undefined &&
|
|
93
|
+
repair.feedback.length > 0
|
|
94
|
+
) {
|
|
95
|
+
ctx.report({
|
|
96
|
+
kind: "repair",
|
|
97
|
+
task: ctx.task,
|
|
98
|
+
message: `${tool}:L3-re-ask`,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return { value: null, feedback: repair.feedback };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
ctx.report({
|
|
105
|
+
kind: "tool",
|
|
106
|
+
task: ctx.task,
|
|
107
|
+
message: `tool_input_rejected:${tool}`,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return { value: null };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Log a tool rejection (scope / size / match failure) so it's measurable. */
|
|
114
|
+
export function reject(
|
|
115
|
+
ctx: IToolContext,
|
|
116
|
+
tool: string,
|
|
117
|
+
reason: string
|
|
118
|
+
): string {
|
|
119
|
+
ctx.report({
|
|
120
|
+
kind: "tool",
|
|
121
|
+
task: ctx.task,
|
|
122
|
+
message: `tool_rejected:${tool} (${reason})`,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return reason;
|
|
126
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ITtsrRule } from "./ttsr";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Built-in TTSR rules: code quality patterns to abort and correct.
|
|
5
|
+
* All scope tool-args (source of the problem), fileGlobs target src/**\/*.ts(x).
|
|
6
|
+
* Each rule guides the model toward the matching gate rule.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_TTSR_RULES: readonly ITtsrRule[] = [
|
|
9
|
+
{
|
|
10
|
+
name: "no-as-any",
|
|
11
|
+
condition: [/\bas\s+any\b/],
|
|
12
|
+
scope: "tool-args",
|
|
13
|
+
fileGlobs: ["src/**/*.ts", "src/**/*.tsx"],
|
|
14
|
+
guidance:
|
|
15
|
+
"Never use 'as any'. If the type is unknown, use 'unknown' or a proper type. " +
|
|
16
|
+
"If the API is untyped, consider a declaration file.",
|
|
17
|
+
repeatMode: "cooldown",
|
|
18
|
+
repeatGap: 5,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "no-ts-suppression",
|
|
22
|
+
condition: [/@ts-(?:ignore|nocheck)/],
|
|
23
|
+
scope: "tool-args",
|
|
24
|
+
fileGlobs: ["src/**/*.ts", "src/**/*.tsx"],
|
|
25
|
+
guidance:
|
|
26
|
+
"Never suppress TypeScript with @ts-ignore/@ts-nocheck. Fix the real error; " +
|
|
27
|
+
"if the library is untyped, add a declaration file instead.",
|
|
28
|
+
repeatMode: "cooldown",
|
|
29
|
+
repeatGap: 5,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "no-empty-catch",
|
|
33
|
+
condition: [/catch\s*(?:\([^)]*\))?\s*\{\s*\}/],
|
|
34
|
+
scope: "tool-args",
|
|
35
|
+
fileGlobs: ["src/**/*.ts", "src/**/*.tsx"],
|
|
36
|
+
guidance:
|
|
37
|
+
"Empty catch blocks hide errors. Log them or handle them: " +
|
|
38
|
+
"catch (e) { console.error(e); } at minimum.",
|
|
39
|
+
repeatMode: "cooldown",
|
|
40
|
+
repeatGap: 5,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "no-console-log",
|
|
44
|
+
condition: [/\bconsole\.(?:log|debug)\s*\(/],
|
|
45
|
+
scope: "tool-args",
|
|
46
|
+
fileGlobs: ["src/**/*.ts", "src/**/*.tsx"],
|
|
47
|
+
guidance:
|
|
48
|
+
"Remove console.log/debug before shipping. Use a logger or remove the line. " +
|
|
49
|
+
"Tests can call console.log; production code must not.",
|
|
50
|
+
repeatMode: "cooldown",
|
|
51
|
+
repeatGap: 5,
|
|
52
|
+
},
|
|
53
|
+
];
|
package/src/loop/ttsr.ts
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { isArray, isRecord } from "../lib/guards";
|
|
2
|
+
import { matchesAnyGlobPattern } from "../rule-packs/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A TTSR rule — triggers on matching stream text, aborts generation, and injects
|
|
6
|
+
* corrective guidance on retry. Rules watch specific channels (prose vs tool args)
|
|
7
|
+
* and optionally scope to files matching a glob.
|
|
8
|
+
*/
|
|
9
|
+
export interface ITtsrRule {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
/** Regex patterns (as source strings for JSON, or RegExp objects for code).
|
|
12
|
+
* Compiled once at manager init. Each is OR'd; any match fires. */
|
|
13
|
+
readonly condition: readonly (string | RegExp)[];
|
|
14
|
+
/** Which stream channel(s) to monitor: "content" (prose), "tool-args" (tool call
|
|
15
|
+
* arguments being assembled), or "both". */
|
|
16
|
+
readonly scope: "content" | "tool-args" | "both";
|
|
17
|
+
/** Optional file globs: for tool-args scope, only match when the edit/create
|
|
18
|
+
* targets a file path matching one of these globs. */
|
|
19
|
+
readonly fileGlobs?: readonly string[];
|
|
20
|
+
/** Guidance text (≤300 chars) appended to the corrective message on retry. */
|
|
21
|
+
readonly guidance: string;
|
|
22
|
+
/** "once" = fires at most once per session; "cooldown" = re-arms after repeatGap
|
|
23
|
+
* turns without a match. */
|
|
24
|
+
readonly repeatMode: "once" | "cooldown";
|
|
25
|
+
/** Gap (in completed turns) before a "cooldown"-mode rule can re-fire. */
|
|
26
|
+
readonly repeatGap?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Match context: information about the stream being monitored. */
|
|
30
|
+
export interface IMatchContext {
|
|
31
|
+
source: "content" | "tool-args";
|
|
32
|
+
/** Current file being edited/created (when tool-args scope), if available. */
|
|
33
|
+
currentFile?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Manages TTSR rule matching and firing. Keeps a rolling buffer per scope/channel
|
|
38
|
+
* to catch patterns spanning chunk boundaries; respects repeat policy + scope gates.
|
|
39
|
+
*/
|
|
40
|
+
export class TtsrManager {
|
|
41
|
+
private disabled = false;
|
|
42
|
+
|
|
43
|
+
private readonly rules: Map<string, ITtsrRule> = new Map<string, ITtsrRule>();
|
|
44
|
+
private readonly compiledConditions: Map<string, RegExp[]> = new Map<
|
|
45
|
+
string,
|
|
46
|
+
RegExp[]
|
|
47
|
+
>();
|
|
48
|
+
private buffers: Record<"content" | "tool-args", string> = {
|
|
49
|
+
content: "",
|
|
50
|
+
"tool-args": "",
|
|
51
|
+
};
|
|
52
|
+
private readonly turnCounts: Map<string, number> = new Map<string, number>();
|
|
53
|
+
private messageCount = 0;
|
|
54
|
+
|
|
55
|
+
/** Max chars to retain in rolling buffer (patterns spanning ~10 chunks). */
|
|
56
|
+
private readonly BUFFER_SIZE = 512;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Register a rule. Validates regex conditions; skips invalid or duplicate rules.
|
|
60
|
+
* Returns true if registered, false if skipped.
|
|
61
|
+
*/
|
|
62
|
+
addRule(rule: ITtsrRule): boolean {
|
|
63
|
+
if (this.rules.has(rule.name)) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const compiled: RegExp[] = [];
|
|
68
|
+
|
|
69
|
+
for (const pattern of rule.condition) {
|
|
70
|
+
try {
|
|
71
|
+
if (pattern instanceof RegExp) {
|
|
72
|
+
compiled.push(pattern);
|
|
73
|
+
} else {
|
|
74
|
+
compiled.push(new RegExp(pattern));
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Invalid regex — skip this rule entirely.
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (compiled.length === 0) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.rules.set(rule.name, rule);
|
|
87
|
+
this.compiledConditions.set(rule.name, compiled);
|
|
88
|
+
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Feed a delta into the rolling buffer and check all rules. Returns the first
|
|
93
|
+
* matching rule or null. Accumulates text into buffers per source channel. */
|
|
94
|
+
/** Permanently stop matching (used when the per-task interrupt cap is hit). */
|
|
95
|
+
disable(): void {
|
|
96
|
+
this.disabled = true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
checkDelta(text: string, context: IMatchContext): ITtsrRule | null {
|
|
100
|
+
if (this.disabled) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const bufferKey = context.source;
|
|
105
|
+
|
|
106
|
+
this.buffers[bufferKey] += text;
|
|
107
|
+
|
|
108
|
+
// Trim buffer to max size, keeping the tail (so patterns spanning boundaries match).
|
|
109
|
+
if (this.buffers[bufferKey].length > this.BUFFER_SIZE) {
|
|
110
|
+
this.buffers[bufferKey] = this.buffers[bufferKey].slice(
|
|
111
|
+
-this.BUFFER_SIZE
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const buf = this.buffers[bufferKey];
|
|
116
|
+
|
|
117
|
+
// Check all rules; return first match that passes repeat policy and scope gates.
|
|
118
|
+
for (const [ruleName, rule] of this.rules) {
|
|
119
|
+
if (!this.isScopeActive(rule, context)) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!this.isFileScopeSatisfied(rule, context)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!this.isRepeatEligible(ruleName, rule)) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (this.doesMatch(ruleName, buf)) {
|
|
132
|
+
return rule;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Mark a rule as fired at the current turn (for repeat policy tracking). */
|
|
140
|
+
markFired(ruleName: string, turn: number): void {
|
|
141
|
+
this.turnCounts.set(ruleName, turn);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Increment message count (call on turn_end for cooldown gap accounting). */
|
|
145
|
+
incrementTurnCount(): void {
|
|
146
|
+
this.messageCount += 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Reset the rolling buffers (call on turn start so each turn's patterns start fresh). */
|
|
150
|
+
resetBuffer(): void {
|
|
151
|
+
this.buffers.content = "";
|
|
152
|
+
this.buffers["tool-args"] = "";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** fileGlobs constrain tool-args matches to edits of matching files. An
|
|
156
|
+
* unknown target (path not yet streamed) holds fire — conservative. */
|
|
157
|
+
private isFileScopeSatisfied(
|
|
158
|
+
rule: ITtsrRule,
|
|
159
|
+
context: IMatchContext
|
|
160
|
+
): boolean {
|
|
161
|
+
if (rule.fileGlobs === undefined || rule.fileGlobs.length === 0) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (context.source !== "tool-args") {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (context.currentFile === undefined) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return matchesAnyGlobPattern(context.currentFile, rule.fileGlobs);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Check if a rule's scope includes the current source. */
|
|
177
|
+
private isScopeActive(rule: ITtsrRule, context: IMatchContext): boolean {
|
|
178
|
+
if (rule.scope === "both") {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return rule.scope === context.source;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Check if a rule is eligible to fire based on repeat policy. */
|
|
186
|
+
private isRepeatEligible(ruleName: string, rule: ITtsrRule): boolean {
|
|
187
|
+
const lastFired = this.turnCounts.get(ruleName);
|
|
188
|
+
|
|
189
|
+
if (lastFired === undefined) {
|
|
190
|
+
return true; // Never fired before.
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (rule.repeatMode === "once") {
|
|
194
|
+
return false; // Fired once, never again.
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const gap = rule.repeatGap ?? 1;
|
|
198
|
+
|
|
199
|
+
return this.messageCount - lastFired >= gap;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Check if any regex condition matches the buffer. */
|
|
203
|
+
private doesMatch(ruleName: string, buf: string): boolean {
|
|
204
|
+
const conditions = this.compiledConditions.get(ruleName);
|
|
205
|
+
|
|
206
|
+
if (!conditions) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return conditions.some((re) => re.test(buf));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Extract scope from parsed item, validating the value. */
|
|
215
|
+
function extractScope(scopeValue: unknown): "content" | "tool-args" | "both" {
|
|
216
|
+
if (
|
|
217
|
+
scopeValue === "tool-args" ||
|
|
218
|
+
scopeValue === "content" ||
|
|
219
|
+
scopeValue === "both"
|
|
220
|
+
) {
|
|
221
|
+
return scopeValue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return "content";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Extract conditions (regex patterns) from parsed item. */
|
|
228
|
+
function extractConditions(conditionValue: unknown): string[] {
|
|
229
|
+
if (isArray(conditionValue)) {
|
|
230
|
+
return conditionValue.filter((x) => typeof x === "string");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (typeof conditionValue === "string") {
|
|
234
|
+
return [conditionValue];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Extract optional file globs from parsed item. */
|
|
241
|
+
function extractFileGlobs(fileGlobsValue: unknown): string[] | undefined {
|
|
242
|
+
if (!isArray(fileGlobsValue)) {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const globs = fileGlobsValue.filter((x) => typeof x === "string");
|
|
247
|
+
|
|
248
|
+
return globs.length > 0 ? globs : undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Parse .tsforge/rules.json (optional project rules). */
|
|
252
|
+
export function parseProjectRules(content: string): ITtsrRule[] {
|
|
253
|
+
let parsed: unknown;
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
parsed = JSON.parse(content);
|
|
257
|
+
} catch {
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!isArray(parsed)) {
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const rules: ITtsrRule[] = [];
|
|
266
|
+
|
|
267
|
+
for (const item of parsed) {
|
|
268
|
+
if (!isRecord(item) || typeof item.name !== "string") {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const condition = extractConditions(item.condition);
|
|
273
|
+
const guidance = typeof item.guidance === "string" ? item.guidance : "";
|
|
274
|
+
|
|
275
|
+
if (condition.length === 0 || guidance.length === 0) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const scope = extractScope(item.scope);
|
|
280
|
+
const repeatMode = item.repeatMode === "cooldown" ? "cooldown" : "once";
|
|
281
|
+
const repeatGap =
|
|
282
|
+
typeof item.repeatGap === "number" && item.repeatGap >= 0
|
|
283
|
+
? Math.floor(item.repeatGap)
|
|
284
|
+
: undefined;
|
|
285
|
+
const fileGlobs = extractFileGlobs(item.fileGlobs);
|
|
286
|
+
|
|
287
|
+
const rule = createRule(
|
|
288
|
+
item.name,
|
|
289
|
+
condition,
|
|
290
|
+
scope,
|
|
291
|
+
guidance,
|
|
292
|
+
repeatMode,
|
|
293
|
+
repeatGap,
|
|
294
|
+
fileGlobs
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
rules.push(rule);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return rules;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Create a rule from parsed/validated components. */
|
|
304
|
+
function createRule(
|
|
305
|
+
name: string,
|
|
306
|
+
condition: readonly (string | RegExp)[],
|
|
307
|
+
scope: "content" | "tool-args" | "both",
|
|
308
|
+
guidance: string,
|
|
309
|
+
repeatMode: "once" | "cooldown",
|
|
310
|
+
repeatGap: number | undefined,
|
|
311
|
+
fileGlobs: string[] | undefined
|
|
312
|
+
): ITtsrRule {
|
|
313
|
+
return {
|
|
314
|
+
name,
|
|
315
|
+
condition,
|
|
316
|
+
scope,
|
|
317
|
+
guidance,
|
|
318
|
+
repeatMode,
|
|
319
|
+
...(fileGlobs && fileGlobs.length > 0 ? { fileGlobs } : {}),
|
|
320
|
+
...(repeatGap !== undefined && repeatGap > 0 ? { repeatGap } : {}),
|
|
321
|
+
};
|
|
322
|
+
}
|