@kody-ade/kody-engine 0.2.47 → 0.2.48

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/dist/bin/kody2.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "@kody-ade/kody-engine",
6
- version: "0.2.47",
6
+ version: "0.2.48",
7
7
  description: "kody2 \u2014 autonomous development engine. Single-session Claude Code agent behind a generic executor + declarative executable profiles.",
8
8
  license: "MIT",
9
9
  type: "module",
@@ -51,8 +51,8 @@ var package_default = {
51
51
 
52
52
  // src/chat-cli.ts
53
53
  import { execFileSync as execFileSync20 } from "child_process";
54
- import * as fs19 from "fs";
55
- import * as path16 from "path";
54
+ import * as fs22 from "fs";
55
+ import * as path19 from "path";
56
56
 
57
57
  // src/chat/events.ts
58
58
  import * as fs from "fs";
@@ -529,8 +529,8 @@ async function emit(sink, type, sessionId, suffix, payload) {
529
529
 
530
530
  // src/kody2-cli.ts
531
531
  import { execFileSync as execFileSync19 } from "child_process";
532
- import * as fs18 from "fs";
533
- import * as path15 from "path";
532
+ import * as fs21 from "fs";
533
+ import * as path18 from "path";
534
534
 
535
535
  // src/dispatch.ts
536
536
  import * as fs5 from "fs";
@@ -572,6 +572,9 @@ function autoDispatch(opts) {
572
572
  if (/\bresolve\b/.test(afterTag)) {
573
573
  return { executable: "resolve", cliArgs: { pr: targetNum }, target: targetNum };
574
574
  }
575
+ if (/\bui-review\b/.test(afterTag)) {
576
+ return { executable: "ui-review", cliArgs: { pr: targetNum }, target: targetNum };
577
+ }
575
578
  if (/\breview\b/.test(afterTag)) {
576
579
  return { executable: "review", cliArgs: { pr: targetNum }, target: targetNum };
577
580
  }
@@ -633,8 +636,8 @@ function extractFeedback(afterTag) {
633
636
  }
634
637
 
635
638
  // src/executor.ts
636
- import * as fs17 from "fs";
637
- import * as path14 from "path";
639
+ import * as fs20 from "fs";
640
+ import * as path17 from "path";
638
641
 
639
642
  // src/litellm.ts
640
643
  import { execFileSync, spawn } from "child_process";
@@ -1097,29 +1100,11 @@ function noteFromAction(action) {
1097
1100
  }
1098
1101
  function renderStateComment(state) {
1099
1102
  const lines = [];
1100
- lines.push(STATE_BEGIN);
1101
- lines.push("");
1102
- lines.push("```json");
1103
- lines.push(
1104
- JSON.stringify(
1105
- {
1106
- schemaVersion: state.schemaVersion,
1107
- core: state.core,
1108
- artifacts: state.artifacts ?? {},
1109
- executables: state.executables,
1110
- history: state.history,
1111
- ...state.flow ? { flow: state.flow } : {}
1112
- },
1113
- null,
1114
- 2
1115
- )
1116
- );
1117
- lines.push("```");
1118
- lines.push("");
1119
- lines.push(STATE_END);
1120
- lines.push("");
1121
- lines.push("## kody2 task state");
1103
+ lines.push("## \u{1F4CB} kody2 task state");
1122
1104
  lines.push("");
1105
+ if (state.flow) {
1106
+ lines.push(`- **Flow:** \`${state.flow.name}\` (step: \`${state.flow.step}\`)`);
1107
+ }
1123
1108
  lines.push(`- **Phase:** \`${state.core.phase}\` **Status:** \`${state.core.status}\``);
1124
1109
  if (state.core.currentExecutable) {
1125
1110
  lines.push(`- **Last executable:** \`${state.core.currentExecutable}\``);
@@ -1146,6 +1131,31 @@ function renderStateComment(state) {
1146
1131
  }
1147
1132
  lines.push("");
1148
1133
  }
1134
+ lines.push("<details>");
1135
+ lines.push("<summary>Raw state (JSON)</summary>");
1136
+ lines.push("");
1137
+ lines.push(STATE_BEGIN);
1138
+ lines.push("");
1139
+ lines.push("```json");
1140
+ lines.push(
1141
+ JSON.stringify(
1142
+ {
1143
+ schemaVersion: state.schemaVersion,
1144
+ core: state.core,
1145
+ artifacts: state.artifacts ?? {},
1146
+ executables: state.executables,
1147
+ history: state.history,
1148
+ ...state.flow ? { flow: state.flow } : {}
1149
+ },
1150
+ null,
1151
+ 2
1152
+ )
1153
+ );
1154
+ lines.push("```");
1155
+ lines.push("");
1156
+ lines.push(STATE_END);
1157
+ lines.push("");
1158
+ lines.push("</details>");
1149
1159
  return lines.join("\n");
1150
1160
  }
1151
1161
  function readTaskState(target, number, cwd) {
@@ -1816,6 +1826,491 @@ function formatToolsUsage(profile) {
1816
1826
  return lines.join("\n");
1817
1827
  }
1818
1828
 
1829
+ // src/scripts/discoverQaContext.ts
1830
+ import * as fs13 from "fs";
1831
+ import * as path12 from "path";
1832
+
1833
+ // src/scripts/frameworkDetectors.ts
1834
+ import * as fs12 from "fs";
1835
+ import * as path11 from "path";
1836
+ function detectFrameworks(cwd) {
1837
+ const out = [];
1838
+ let deps = {};
1839
+ try {
1840
+ const pkg = JSON.parse(fs12.readFileSync(path11.join(cwd, "package.json"), "utf-8"));
1841
+ deps = { ...pkg.dependencies, ...pkg.devDependencies };
1842
+ } catch {
1843
+ return out;
1844
+ }
1845
+ if (deps.payload || deps["@payloadcms/next"]) {
1846
+ out.push({
1847
+ name: "payload-cms",
1848
+ version: deps.payload ?? deps["@payloadcms/next"] ?? null,
1849
+ configFile: findFile(cwd, ["payload.config.ts", "payload-config.ts", "src/payload.config.ts"])
1850
+ });
1851
+ }
1852
+ if (deps["next-auth"]) {
1853
+ out.push({
1854
+ name: "nextauth",
1855
+ version: deps["next-auth"] ?? null,
1856
+ configFile: findFile(cwd, ["auth.ts", "auth.config.ts", "src/auth.ts", "src/auth.config.ts"])
1857
+ });
1858
+ }
1859
+ if (deps.prisma || deps["@prisma/client"]) {
1860
+ out.push({
1861
+ name: "prisma",
1862
+ version: deps.prisma ?? deps["@prisma/client"] ?? null,
1863
+ configFile: findFile(cwd, ["prisma/schema.prisma"])
1864
+ });
1865
+ }
1866
+ if (deps.next) {
1867
+ out.push({
1868
+ name: "nextjs",
1869
+ version: deps.next ?? null,
1870
+ configFile: findFile(cwd, ["next.config.ts", "next.config.mjs", "next.config.js"])
1871
+ });
1872
+ }
1873
+ return out;
1874
+ }
1875
+ function findFile(cwd, candidates) {
1876
+ for (const c of candidates) {
1877
+ if (fs12.existsSync(path11.join(cwd, c))) return c;
1878
+ }
1879
+ return null;
1880
+ }
1881
+ var COLLECTION_DIRS = [
1882
+ "src/server/payload/collections",
1883
+ "src/payload/collections",
1884
+ "src/collections",
1885
+ "payload/collections"
1886
+ ];
1887
+ function discoverPayloadCollections(cwd) {
1888
+ const out = [];
1889
+ for (const dir of COLLECTION_DIRS) {
1890
+ const full = path11.join(cwd, dir);
1891
+ if (!fs12.existsSync(full)) continue;
1892
+ let files;
1893
+ try {
1894
+ files = fs12.readdirSync(full).filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
1895
+ } catch {
1896
+ continue;
1897
+ }
1898
+ for (const file of files) {
1899
+ try {
1900
+ const filePath = path11.join(full, file);
1901
+ const content = fs12.readFileSync(filePath, "utf-8").slice(0, 1e4);
1902
+ const slugMatch = content.match(/slug:\s*['"]([a-z0-9-]+)['"]/);
1903
+ if (!slugMatch) continue;
1904
+ const slug = slugMatch[1];
1905
+ const name = file.replace(/\.(ts|tsx)$/, "");
1906
+ const fields = [];
1907
+ const fieldMatches = content.matchAll(/name:\s*['"]([a-zA-Z_][a-zA-Z0-9_]*)['"]/g);
1908
+ for (const m of fieldMatches) {
1909
+ if (!fields.includes(m[1])) fields.push(m[1]);
1910
+ }
1911
+ const hasAdmin = /components:\s*\{/.test(content) || /Field:\s*['"]/.test(content) || /Cell:\s*['"]/.test(content) || /views:\s*\{/.test(content);
1912
+ out.push({
1913
+ name,
1914
+ slug,
1915
+ filePath: path11.relative(cwd, filePath),
1916
+ fields: fields.slice(0, 20),
1917
+ hasAdmin
1918
+ });
1919
+ } catch {
1920
+ }
1921
+ }
1922
+ }
1923
+ return out;
1924
+ }
1925
+ var ADMIN_COMPONENT_DIRS = ["src/ui/admin", "src/admin/components", "src/components/admin"];
1926
+ function discoverAdminComponents(cwd, collections) {
1927
+ const out = [];
1928
+ for (const dir of ADMIN_COMPONENT_DIRS) {
1929
+ const full = path11.join(cwd, dir);
1930
+ if (!fs12.existsSync(full)) continue;
1931
+ let entries;
1932
+ try {
1933
+ entries = fs12.readdirSync(full, { withFileTypes: true });
1934
+ } catch {
1935
+ continue;
1936
+ }
1937
+ for (const entry of entries) {
1938
+ const entryPath = path11.join(full, entry.name);
1939
+ let name;
1940
+ let filePath;
1941
+ if (entry.isDirectory()) {
1942
+ const indexFile = ["index.tsx", "index.ts", "index.jsx", "index.js"].find(
1943
+ (f) => fs12.existsSync(path11.join(entryPath, f))
1944
+ );
1945
+ if (!indexFile) continue;
1946
+ name = entry.name;
1947
+ filePath = path11.relative(cwd, path11.join(entryPath, indexFile));
1948
+ } else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
1949
+ name = entry.name.replace(/\.(tsx?|jsx?)$/, "");
1950
+ filePath = path11.relative(cwd, entryPath);
1951
+ } else {
1952
+ continue;
1953
+ }
1954
+ let usedInCollection = null;
1955
+ if (collections) {
1956
+ for (const col of collections) {
1957
+ try {
1958
+ const colContent = fs12.readFileSync(path11.join(cwd, col.filePath), "utf-8");
1959
+ if (colContent.includes(name)) {
1960
+ usedInCollection = col.slug;
1961
+ break;
1962
+ }
1963
+ } catch {
1964
+ }
1965
+ }
1966
+ }
1967
+ out.push({ name, filePath, usedInCollection });
1968
+ }
1969
+ }
1970
+ return out;
1971
+ }
1972
+ var HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
1973
+ function scanApiRoutes(cwd) {
1974
+ const out = [];
1975
+ const appDirs = ["src/app", "app"];
1976
+ for (const appDir of appDirs) {
1977
+ const apiDir = path11.join(cwd, appDir, "api");
1978
+ if (!fs12.existsSync(apiDir)) continue;
1979
+ walkApiRoutes(apiDir, "/api", cwd, out);
1980
+ break;
1981
+ }
1982
+ return out;
1983
+ }
1984
+ function walkApiRoutes(dir, prefix, cwd, out) {
1985
+ let entries;
1986
+ try {
1987
+ entries = fs12.readdirSync(dir, { withFileTypes: true });
1988
+ } catch {
1989
+ return;
1990
+ }
1991
+ const routeFile = entries.find((e) => e.isFile() && /^route\.(ts|js|tsx|jsx)$/.test(e.name));
1992
+ if (routeFile) {
1993
+ try {
1994
+ const content = fs12.readFileSync(path11.join(dir, routeFile.name), "utf-8").slice(0, 5e3);
1995
+ const methods = HTTP_METHODS.filter((m) => new RegExp(`export\\s+(?:async\\s+)?function\\s+${m}\\b`).test(content));
1996
+ if (methods.length > 0) {
1997
+ out.push({
1998
+ path: prefix,
1999
+ methods,
2000
+ filePath: path11.relative(cwd, path11.join(dir, routeFile.name))
2001
+ });
2002
+ }
2003
+ } catch {
2004
+ }
2005
+ }
2006
+ for (const entry of entries) {
2007
+ if (!entry.isDirectory()) continue;
2008
+ if (entry.name === "node_modules" || entry.name === ".next") continue;
2009
+ let segment = entry.name;
2010
+ if (segment.startsWith("(") && segment.endsWith(")")) {
2011
+ walkApiRoutes(path11.join(dir, entry.name), prefix, cwd, out);
2012
+ continue;
2013
+ }
2014
+ if (segment.startsWith("[[") && segment.endsWith("]]")) {
2015
+ segment = `:${segment.slice(2, -2)}?`;
2016
+ } else if (segment.startsWith("[") && segment.endsWith("]")) {
2017
+ segment = `:${segment.slice(1, -1)}`;
2018
+ }
2019
+ walkApiRoutes(path11.join(dir, entry.name), `${prefix}/${segment}`, cwd, out);
2020
+ }
2021
+ }
2022
+ var BUILTIN_ENV_VARS = /* @__PURE__ */ new Set([
2023
+ "NODE_ENV",
2024
+ "HOME",
2025
+ "PATH",
2026
+ "USER",
2027
+ "SHELL",
2028
+ "TERM",
2029
+ "LANG",
2030
+ "PWD",
2031
+ "HOSTNAME",
2032
+ "PORT",
2033
+ "CI",
2034
+ "GITHUB_ACTIONS"
2035
+ ]);
2036
+ function scanEnvVars(cwd) {
2037
+ const candidates = [".env.example", ".env.local.example", ".env.template"];
2038
+ for (const envFile of candidates) {
2039
+ const envPath = path11.join(cwd, envFile);
2040
+ if (!fs12.existsSync(envPath)) continue;
2041
+ try {
2042
+ const content = fs12.readFileSync(envPath, "utf-8");
2043
+ const vars = [];
2044
+ for (const line of content.split("\n")) {
2045
+ const trimmed = line.trim();
2046
+ if (!trimmed || trimmed.startsWith("#")) continue;
2047
+ const match = trimmed.match(/^([A-Z][A-Z0-9_]*)=/);
2048
+ if (match && !BUILTIN_ENV_VARS.has(match[1])) vars.push(match[1]);
2049
+ }
2050
+ return vars;
2051
+ } catch {
2052
+ return [];
2053
+ }
2054
+ }
2055
+ return [];
2056
+ }
2057
+
2058
+ // src/scripts/discoverQaContext.ts
2059
+ var MAX_SERIALIZED_LENGTH = 8e3;
2060
+ function runQaDiscovery(cwd) {
2061
+ const out = {
2062
+ routes: [],
2063
+ authFiles: [],
2064
+ loginPage: null,
2065
+ adminPath: null,
2066
+ roles: [],
2067
+ devCommand: "",
2068
+ devPort: 3e3,
2069
+ frameworks: [],
2070
+ collections: [],
2071
+ adminComponents: [],
2072
+ apiRoutes: [],
2073
+ envVars: []
2074
+ };
2075
+ detectDevServer(cwd, out);
2076
+ scanFrontendRoutes(cwd, out);
2077
+ detectAuthFiles(cwd, out);
2078
+ detectRoles(cwd, out);
2079
+ out.frameworks = detectFrameworks(cwd);
2080
+ const hasPayload = out.frameworks.some((f) => f.name === "payload-cms");
2081
+ if (hasPayload) out.collections = discoverPayloadCollections(cwd);
2082
+ out.adminComponents = discoverAdminComponents(cwd, out.collections.length > 0 ? out.collections : void 0);
2083
+ out.apiRoutes = scanApiRoutes(cwd);
2084
+ out.envVars = scanEnvVars(cwd);
2085
+ if (hasPayload && !out.adminPath) out.adminPath = "/admin";
2086
+ return out;
2087
+ }
2088
+ function detectDevServer(cwd, out) {
2089
+ try {
2090
+ const pkg = JSON.parse(fs13.readFileSync(path12.join(cwd, "package.json"), "utf-8"));
2091
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
2092
+ const pm = fs13.existsSync(path12.join(cwd, "pnpm-lock.yaml")) ? "pnpm" : fs13.existsSync(path12.join(cwd, "yarn.lock")) ? "yarn" : fs13.existsSync(path12.join(cwd, "bun.lockb")) ? "bun" : "npm";
2093
+ if (pkg.scripts?.dev) out.devCommand = `${pm} dev`;
2094
+ if (allDeps.next || allDeps.nuxt) out.devPort = 3e3;
2095
+ else if (allDeps.vite) out.devPort = 5173;
2096
+ } catch {
2097
+ }
2098
+ }
2099
+ function scanFrontendRoutes(cwd, out) {
2100
+ const appDirs = ["src/app", "app"];
2101
+ for (const appDir of appDirs) {
2102
+ const full = path12.join(cwd, appDir);
2103
+ if (!fs13.existsSync(full)) continue;
2104
+ walkFrontendRoutes(full, "", out);
2105
+ break;
2106
+ }
2107
+ }
2108
+ function walkFrontendRoutes(dir, prefix, out) {
2109
+ let entries;
2110
+ try {
2111
+ entries = fs13.readdirSync(dir, { withFileTypes: true });
2112
+ } catch {
2113
+ return;
2114
+ }
2115
+ const hasPage = entries.some((e) => e.isFile() && /^page\.(tsx?|jsx?)$/.test(e.name));
2116
+ if (hasPage) {
2117
+ const routePath = prefix || "/";
2118
+ const group = prefix.startsWith("/admin") ? "admin" : prefix.includes("/login") || prefix.includes("/signup") ? "auth" : prefix.includes("/api") ? "api" : "frontend";
2119
+ out.routes.push({ path: routePath, group });
2120
+ if (prefix.includes("/login") && !out.loginPage) out.loginPage = routePath;
2121
+ if (prefix.startsWith("/admin") && !out.adminPath) out.adminPath = prefix;
2122
+ }
2123
+ for (const entry of entries) {
2124
+ if (!entry.isDirectory()) continue;
2125
+ if (entry.name === "node_modules" || entry.name === ".next") continue;
2126
+ let segment = entry.name;
2127
+ if (segment.startsWith("(") && segment.endsWith(")")) {
2128
+ walkFrontendRoutes(path12.join(dir, entry.name), prefix, out);
2129
+ continue;
2130
+ }
2131
+ if (segment.startsWith("[[") && segment.endsWith("]]")) {
2132
+ segment = `:${segment.slice(2, -2)}?`;
2133
+ } else if (segment.startsWith("[") && segment.endsWith("]")) {
2134
+ segment = `:${segment.slice(1, -1)}`;
2135
+ }
2136
+ walkFrontendRoutes(path12.join(dir, entry.name), `${prefix}/${segment}`, out);
2137
+ }
2138
+ }
2139
+ function detectAuthFiles(cwd, out) {
2140
+ const candidates = [
2141
+ "middleware.ts",
2142
+ "middleware.js",
2143
+ "src/middleware.ts",
2144
+ "src/middleware.js",
2145
+ "src/app/api/auth",
2146
+ "src/auth",
2147
+ "src/lib/auth",
2148
+ "auth.config.ts",
2149
+ "auth.ts",
2150
+ "src/app/api/oauth"
2151
+ ];
2152
+ for (const c of candidates) {
2153
+ if (fs13.existsSync(path12.join(cwd, c))) out.authFiles.push(c);
2154
+ }
2155
+ }
2156
+ function detectRoles(cwd, out) {
2157
+ const rolePaths = ["src/types", "src/lib", "src/utils", "src/constants", "src/access", "src/collections"];
2158
+ for (const rp of rolePaths) {
2159
+ const dir = path12.join(cwd, rp);
2160
+ if (!fs13.existsSync(dir)) continue;
2161
+ let files;
2162
+ try {
2163
+ files = fs13.readdirSync(dir).filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
2164
+ } catch {
2165
+ continue;
2166
+ }
2167
+ for (const f of files) {
2168
+ try {
2169
+ const content = fs13.readFileSync(path12.join(dir, f), "utf-8").slice(0, 5e3);
2170
+ const roleMatches = content.match(/(?:role|Role|ROLE)\s*[=:]\s*['"](\w+)['"]/g);
2171
+ if (roleMatches) {
2172
+ for (const m of roleMatches) {
2173
+ const val = m.match(/['"](\w+)['"]/);
2174
+ if (val && !out.roles.includes(val[1])) out.roles.push(val[1]);
2175
+ }
2176
+ }
2177
+ const enumMatch = content.match(/(?:enum|type)\s+\w*[Rr]ole\w*\s*[={]([^}]+)/s);
2178
+ if (enumMatch) {
2179
+ const vals = enumMatch[1].match(/['"](\w+)['"]/g);
2180
+ if (vals) {
2181
+ for (const v of vals) {
2182
+ const clean = v.replace(/['"]/g, "");
2183
+ if (!out.roles.includes(clean)) out.roles.push(clean);
2184
+ }
2185
+ }
2186
+ }
2187
+ } catch {
2188
+ }
2189
+ }
2190
+ }
2191
+ }
2192
+ function serializeDiscoveryForLLM(d) {
2193
+ const sections = [];
2194
+ sections.push(`Dev server: ${d.devCommand || "pnpm dev"} at http://localhost:${d.devPort}`);
2195
+ if (d.loginPage) sections.push(`Login page: ${d.loginPage}`);
2196
+ if (d.adminPath) sections.push(`Admin panel: ${d.adminPath}`);
2197
+ if (d.roles.length > 0) sections.push(`Roles: ${d.roles.join(", ")}`);
2198
+ if (d.frameworks.length > 0) {
2199
+ sections.push(
2200
+ `
2201
+ Frameworks: ${d.frameworks.map((f) => `${f.name}${f.version ? ` (${f.version})` : ""}`).join(", ")}`
2202
+ );
2203
+ }
2204
+ if (d.collections.length > 0) {
2205
+ sections.push("\nCollections (Payload CMS):");
2206
+ for (const col of d.collections.slice(0, 15)) {
2207
+ const fields = col.fields.slice(0, 10).join(", ");
2208
+ let line = `- ${col.slug}: fields=[${fields}]`;
2209
+ if (col.hasAdmin) line += " (has custom admin components)";
2210
+ line += ` \u2014 ${col.filePath}`;
2211
+ sections.push(line);
2212
+ }
2213
+ if (d.collections.length > 15) sections.push(`- ... and ${d.collections.length - 15} more collections`);
2214
+ }
2215
+ if (d.adminComponents.length > 0) {
2216
+ sections.push("\nCustom Admin Components:");
2217
+ for (const comp of d.adminComponents.slice(0, 10)) {
2218
+ let line = `- ${comp.name} (${comp.filePath})`;
2219
+ if (comp.usedInCollection) line += ` \u2192 used in "${comp.usedInCollection}" collection`;
2220
+ sections.push(line);
2221
+ }
2222
+ }
2223
+ if (d.apiRoutes.length > 0) {
2224
+ sections.push("\nAPI Routes:");
2225
+ for (const route of d.apiRoutes.slice(0, 20)) {
2226
+ sections.push(`- ${route.methods.join("/")} ${route.path} \u2014 ${route.filePath}`);
2227
+ }
2228
+ if (d.apiRoutes.length > 20) sections.push(`- ... and ${d.apiRoutes.length - 20} more routes`);
2229
+ }
2230
+ if (d.routes.length > 0) {
2231
+ sections.push("\nFrontend Routes:");
2232
+ for (const route of d.routes.slice(0, 30)) {
2233
+ sections.push(`- [${route.group}] ${route.path}`);
2234
+ }
2235
+ if (d.routes.length > 30) sections.push(`- ... and ${d.routes.length - 30} more routes`);
2236
+ }
2237
+ if (d.envVars.length > 0) sections.push(`
2238
+ Required env vars: ${d.envVars.join(", ")}`);
2239
+ let result = sections.join("\n");
2240
+ if (result.length > MAX_SERIALIZED_LENGTH) {
2241
+ const cutoff = result.lastIndexOf("\n", MAX_SERIALIZED_LENGTH - 20);
2242
+ result = result.slice(0, cutoff > 0 ? cutoff : MAX_SERIALIZED_LENGTH - 20) + "\n... (truncated)";
2243
+ }
2244
+ return result;
2245
+ }
2246
+ function generateQaGuideTemplate(d) {
2247
+ const lines = [];
2248
+ lines.push("# QA guide");
2249
+ lines.push("");
2250
+ lines.push("This file is read by `kody2 ui-review`. Fill in the credential placeholders");
2251
+ lines.push("below and commit \u2014 the agent uses them to log in to your preview deployment.");
2252
+ lines.push("");
2253
+ lines.push("## Test accounts");
2254
+ lines.push("");
2255
+ lines.push("<!-- Replace CHANGE_ME with real credentials for your preview environment.");
2256
+ lines.push(" Remove any role row you don't have an account for. -->");
2257
+ lines.push("");
2258
+ lines.push("| Role | Email | Password |");
2259
+ lines.push("|------|-------|----------|");
2260
+ if (d.roles.length > 0) {
2261
+ for (const role of d.roles) {
2262
+ lines.push(`| ${role} | CHANGE_ME | CHANGE_ME |`);
2263
+ }
2264
+ } else {
2265
+ lines.push("| admin | admin@example.com | CHANGE_ME |");
2266
+ lines.push("| user | user@example.com | CHANGE_ME |");
2267
+ }
2268
+ lines.push("");
2269
+ lines.push("## Login");
2270
+ lines.push("");
2271
+ lines.push(`- Login page: \`${d.loginPage ?? "/login"}\``);
2272
+ if (d.adminPath) lines.push(`- Admin panel: \`${d.adminPath}\``);
2273
+ lines.push("");
2274
+ lines.push("### Steps");
2275
+ lines.push(`1. Navigate to \`${d.loginPage ?? "/login"}\``);
2276
+ lines.push("2. Enter credentials from the table above");
2277
+ lines.push("3. Submit the login form");
2278
+ lines.push("4. Verify the redirect lands on the expected page");
2279
+ lines.push("");
2280
+ if (d.roles.length > 0) {
2281
+ lines.push("## Roles");
2282
+ lines.push("");
2283
+ for (const role of d.roles) lines.push(`- \`${role}\``);
2284
+ lines.push("");
2285
+ }
2286
+ if (d.routes.length > 0) {
2287
+ lines.push("## Key pages");
2288
+ lines.push("");
2289
+ const groups = {};
2290
+ for (const r of d.routes) {
2291
+ if (!groups[r.group]) groups[r.group] = [];
2292
+ groups[r.group].push(r.path);
2293
+ }
2294
+ for (const [group, routes] of Object.entries(groups)) {
2295
+ lines.push(`### ${group[0].toUpperCase()}${group.slice(1)}`);
2296
+ for (const r of routes.slice(0, 15).sort()) lines.push(`- \`${r}\``);
2297
+ if (routes.length > 15) lines.push(`- \u2026 and ${routes.length - 15} more`);
2298
+ lines.push("");
2299
+ }
2300
+ }
2301
+ lines.push("## Notes for the reviewer");
2302
+ lines.push("");
2303
+ lines.push("<!-- Add any repo-specific quirks the UI-review agent should know:");
2304
+ lines.push(" seed data assumptions, feature flags, preview-only behaviors, etc. -->");
2305
+ lines.push("");
2306
+ return lines.join("\n");
2307
+ }
2308
+ var discoverQaContext = async (ctx) => {
2309
+ const discovery = runQaDiscovery(ctx.cwd);
2310
+ ctx.data.qaDiscovery = discovery;
2311
+ ctx.data.qaContext = serializeDiscoveryForLLM(discovery);
2312
+ };
2313
+
1819
2314
  // src/scripts/dispatch.ts
1820
2315
  import { execFileSync as execFileSync7 } from "child_process";
1821
2316
  var API_TIMEOUT_MS3 = 3e4;
@@ -2293,7 +2788,7 @@ function ensureFeatureBranch(issueNumber, title, defaultBranch, cwd) {
2293
2788
 
2294
2789
  // src/gha.ts
2295
2790
  import { execFileSync as execFileSync11 } from "child_process";
2296
- import * as fs12 from "fs";
2791
+ import * as fs14 from "fs";
2297
2792
  function getRunUrl() {
2298
2793
  const server = process.env.GITHUB_SERVER_URL;
2299
2794
  const repo = process.env.GITHUB_REPOSITORY;
@@ -2304,10 +2799,10 @@ function getRunUrl() {
2304
2799
  function reactToTriggerComment(cwd) {
2305
2800
  if (process.env.GITHUB_EVENT_NAME !== "issue_comment") return;
2306
2801
  const eventPath = process.env.GITHUB_EVENT_PATH;
2307
- if (!eventPath || !fs12.existsSync(eventPath)) return;
2802
+ if (!eventPath || !fs14.existsSync(eventPath)) return;
2308
2803
  let event = null;
2309
2804
  try {
2310
- event = JSON.parse(fs12.readFileSync(eventPath, "utf-8"));
2805
+ event = JSON.parse(fs14.readFileSync(eventPath, "utf-8"));
2311
2806
  } catch {
2312
2807
  return;
2313
2808
  }
@@ -2533,35 +3028,35 @@ function tryPostPr2(prNumber, body, cwd) {
2533
3028
 
2534
3029
  // src/scripts/initFlow.ts
2535
3030
  import { execFileSync as execFileSync13 } from "child_process";
2536
- import * as fs14 from "fs";
2537
- import * as path12 from "path";
3031
+ import * as fs17 from "fs";
3032
+ import * as path15 from "path";
2538
3033
 
2539
3034
  // src/registry.ts
2540
- import * as fs13 from "fs";
2541
- import * as path11 from "path";
3035
+ import * as fs15 from "fs";
3036
+ import * as path13 from "path";
2542
3037
  function getExecutablesRoot() {
2543
- const here = path11.dirname(new URL(import.meta.url).pathname);
3038
+ const here = path13.dirname(new URL(import.meta.url).pathname);
2544
3039
  const candidates = [
2545
- path11.join(here, "executables"),
3040
+ path13.join(here, "executables"),
2546
3041
  // dev: src/
2547
- path11.join(here, "..", "executables"),
3042
+ path13.join(here, "..", "executables"),
2548
3043
  // built: dist/bin → dist/executables
2549
- path11.join(here, "..", "src", "executables")
3044
+ path13.join(here, "..", "src", "executables")
2550
3045
  // fallback
2551
3046
  ];
2552
3047
  for (const c of candidates) {
2553
- if (fs13.existsSync(c) && fs13.statSync(c).isDirectory()) return c;
3048
+ if (fs15.existsSync(c) && fs15.statSync(c).isDirectory()) return c;
2554
3049
  }
2555
3050
  return candidates[0];
2556
3051
  }
2557
3052
  function listExecutables(root = getExecutablesRoot()) {
2558
- if (!fs13.existsSync(root)) return [];
2559
- const entries = fs13.readdirSync(root, { withFileTypes: true });
3053
+ if (!fs15.existsSync(root)) return [];
3054
+ const entries = fs15.readdirSync(root, { withFileTypes: true });
2560
3055
  const out = [];
2561
3056
  for (const ent of entries) {
2562
3057
  if (!ent.isDirectory()) continue;
2563
- const profilePath = path11.join(root, ent.name, "profile.json");
2564
- if (fs13.existsSync(profilePath) && fs13.statSync(profilePath).isFile()) {
3058
+ const profilePath = path13.join(root, ent.name, "profile.json");
3059
+ if (fs15.existsSync(profilePath) && fs15.statSync(profilePath).isFile()) {
2565
3060
  out.push({ name: ent.name, profilePath });
2566
3061
  }
2567
3062
  }
@@ -2569,8 +3064,8 @@ function listExecutables(root = getExecutablesRoot()) {
2569
3064
  }
2570
3065
  function hasExecutable(name, root = getExecutablesRoot()) {
2571
3066
  if (!isSafeName(name)) return false;
2572
- const profilePath = path11.join(root, name, "profile.json");
2573
- return fs13.existsSync(profilePath) && fs13.statSync(profilePath).isFile();
3067
+ const profilePath = path13.join(root, name, "profile.json");
3068
+ return fs15.existsSync(profilePath) && fs15.statSync(profilePath).isFile();
2574
3069
  }
2575
3070
  function isSafeName(name) {
2576
3071
  return /^[a-z][a-z0-9-]*$/.test(name) && !name.includes("..");
@@ -2597,11 +3092,31 @@ function parseGenericFlags(argv) {
2597
3092
  return args;
2598
3093
  }
2599
3094
 
3095
+ // src/scripts/loadQaGuide.ts
3096
+ import * as fs16 from "fs";
3097
+ import * as path14 from "path";
3098
+ var QA_GUIDE_REL_PATH = ".kody2/qa-guide.md";
3099
+ var loadQaGuide = async (ctx) => {
3100
+ const full = path14.join(ctx.cwd, QA_GUIDE_REL_PATH);
3101
+ if (!fs16.existsSync(full)) {
3102
+ ctx.data.qaGuide = "";
3103
+ ctx.data.qaGuidePath = "";
3104
+ return;
3105
+ }
3106
+ try {
3107
+ ctx.data.qaGuide = fs16.readFileSync(full, "utf-8");
3108
+ ctx.data.qaGuidePath = QA_GUIDE_REL_PATH;
3109
+ } catch {
3110
+ ctx.data.qaGuide = "";
3111
+ ctx.data.qaGuidePath = "";
3112
+ }
3113
+ };
3114
+
2600
3115
  // src/scripts/initFlow.ts
2601
3116
  function detectPackageManager(cwd) {
2602
- if (fs14.existsSync(path12.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
2603
- if (fs14.existsSync(path12.join(cwd, "yarn.lock"))) return "yarn";
2604
- if (fs14.existsSync(path12.join(cwd, "bun.lockb"))) return "bun";
3117
+ if (fs17.existsSync(path15.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
3118
+ if (fs17.existsSync(path15.join(cwd, "yarn.lock"))) return "yarn";
3119
+ if (fs17.existsSync(path15.join(cwd, "bun.lockb"))) return "bun";
2605
3120
  return "npm";
2606
3121
  }
2607
3122
  function qualityCommandsFor(pm) {
@@ -2722,24 +3237,36 @@ function performInit(cwd, force) {
2722
3237
  const pm = detectPackageManager(cwd);
2723
3238
  const ownerRepo = detectOwnerRepo(cwd);
2724
3239
  const defaultBranch = defaultBranchFromGit(cwd);
2725
- const configPath = path12.join(cwd, "kody.config.json");
2726
- if (fs14.existsSync(configPath) && !force) {
3240
+ const configPath = path15.join(cwd, "kody.config.json");
3241
+ if (fs17.existsSync(configPath) && !force) {
2727
3242
  skipped.push("kody.config.json");
2728
3243
  } else {
2729
3244
  const cfg = makeConfig(pm, ownerRepo, defaultBranch);
2730
- fs14.writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}
3245
+ fs17.writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}
2731
3246
  `);
2732
3247
  wrote.push("kody.config.json");
2733
3248
  }
2734
- const workflowDir = path12.join(cwd, ".github", "workflows");
2735
- const workflowPath = path12.join(workflowDir, "kody2.yml");
2736
- if (fs14.existsSync(workflowPath) && !force) {
3249
+ const workflowDir = path15.join(cwd, ".github", "workflows");
3250
+ const workflowPath = path15.join(workflowDir, "kody2.yml");
3251
+ if (fs17.existsSync(workflowPath) && !force) {
2737
3252
  skipped.push(".github/workflows/kody2.yml");
2738
3253
  } else {
2739
- fs14.mkdirSync(workflowDir, { recursive: true });
2740
- fs14.writeFileSync(workflowPath, WORKFLOW_TEMPLATE);
3254
+ fs17.mkdirSync(workflowDir, { recursive: true });
3255
+ fs17.writeFileSync(workflowPath, WORKFLOW_TEMPLATE);
2741
3256
  wrote.push(".github/workflows/kody2.yml");
2742
3257
  }
3258
+ const hasUi = fs17.existsSync(path15.join(cwd, "src/app")) || fs17.existsSync(path15.join(cwd, "app")) || fs17.existsSync(path15.join(cwd, "pages"));
3259
+ if (hasUi) {
3260
+ const qaGuidePath = path15.join(cwd, QA_GUIDE_REL_PATH);
3261
+ if (fs17.existsSync(qaGuidePath) && !force) {
3262
+ skipped.push(QA_GUIDE_REL_PATH);
3263
+ } else {
3264
+ fs17.mkdirSync(path15.dirname(qaGuidePath), { recursive: true });
3265
+ const discovery = runQaDiscovery(cwd);
3266
+ fs17.writeFileSync(qaGuidePath, generateQaGuideTemplate(discovery));
3267
+ wrote.push(QA_GUIDE_REL_PATH);
3268
+ }
3269
+ }
2743
3270
  for (const exe of listExecutables()) {
2744
3271
  let profile;
2745
3272
  try {
@@ -2748,12 +3275,12 @@ function performInit(cwd, force) {
2748
3275
  continue;
2749
3276
  }
2750
3277
  if (profile.kind !== "scheduled" || !profile.schedule) continue;
2751
- const target = path12.join(workflowDir, `kody2-${exe.name}.yml`);
2752
- if (fs14.existsSync(target) && !force) {
3278
+ const target = path15.join(workflowDir, `kody2-${exe.name}.yml`);
3279
+ if (fs17.existsSync(target) && !force) {
2753
3280
  skipped.push(`.github/workflows/kody2-${exe.name}.yml`);
2754
3281
  continue;
2755
3282
  }
2756
- fs14.writeFileSync(target, renderScheduledWorkflow(exe.name, profile.schedule));
3283
+ fs17.writeFileSync(target, renderScheduledWorkflow(exe.name, profile.schedule));
2757
3284
  wrote.push(`.github/workflows/kody2-${exe.name}.yml`);
2758
3285
  }
2759
3286
  return { wrote, skipped };
@@ -3128,8 +3655,8 @@ REVIEW_POSTED=https://github.com/${ctx.config.github.owner}/${ctx.config.github.
3128
3655
 
3129
3656
  // src/scripts/releaseFlow.ts
3130
3657
  import { execFileSync as execFileSync14, spawnSync } from "child_process";
3131
- import * as fs15 from "fs";
3132
- import * as path13 from "path";
3658
+ import * as fs18 from "fs";
3659
+ import * as path16 from "path";
3133
3660
  function bumpVersion(current, bump) {
3134
3661
  const m = current.match(/^(\d+)\.(\d+)\.(\d+)(.*)$/);
3135
3662
  if (!m) throw new Error(`cannot parse version '${current}' (expected x.y.z[-suffix])`);
@@ -3145,12 +3672,12 @@ function bumpVersion(current, bump) {
3145
3672
  return `${major}.${minor}.${patch}`;
3146
3673
  }
3147
3674
  function updateVersionInFile(file, newVersion, cwd) {
3148
- const abs = path13.join(cwd, file);
3149
- if (!fs15.existsSync(abs)) return false;
3150
- const content = fs15.readFileSync(abs, "utf-8");
3675
+ const abs = path16.join(cwd, file);
3676
+ if (!fs18.existsSync(abs)) return false;
3677
+ const content = fs18.readFileSync(abs, "utf-8");
3151
3678
  const updated = content.replace(/"version"\s*:\s*"[^"]+"/, `"version": "${newVersion}"`);
3152
3679
  if (updated === content) return false;
3153
- fs15.writeFileSync(abs, updated);
3680
+ fs18.writeFileSync(abs, updated);
3154
3681
  return true;
3155
3682
  }
3156
3683
  function generateChangelog(cwd, newVersion, lastTag) {
@@ -3198,19 +3725,19 @@ function generateChangelog(cwd, newVersion, lastTag) {
3198
3725
  return parts.join("\n");
3199
3726
  }
3200
3727
  function prependChangelog(cwd, entry) {
3201
- const p = path13.join(cwd, "CHANGELOG.md");
3728
+ const p = path16.join(cwd, "CHANGELOG.md");
3202
3729
  const header = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n";
3203
- if (fs15.existsSync(p)) {
3204
- const prior = fs15.readFileSync(p, "utf-8");
3730
+ if (fs18.existsSync(p)) {
3731
+ const prior = fs18.readFileSync(p, "utf-8");
3205
3732
  if (/^#\s*Changelog\b/m.test(prior)) {
3206
3733
  const idx = prior.indexOf("\n", prior.indexOf("# Changelog"));
3207
- fs15.writeFileSync(p, `${prior.slice(0, idx + 1)}
3734
+ fs18.writeFileSync(p, `${prior.slice(0, idx + 1)}
3208
3735
  ${entry}${prior.slice(idx + 1)}`);
3209
3736
  } else {
3210
- fs15.writeFileSync(p, `${header}${entry}${prior}`);
3737
+ fs18.writeFileSync(p, `${header}${entry}${prior}`);
3211
3738
  }
3212
3739
  } else {
3213
- fs15.writeFileSync(p, `${header}${entry}`);
3740
+ fs18.writeFileSync(p, `${header}${entry}`);
3214
3741
  }
3215
3742
  }
3216
3743
  function git3(args, cwd, timeout = 6e4) {
@@ -3261,13 +3788,13 @@ var releaseFlow = async (ctx) => {
3261
3788
  };
3262
3789
  async function runPrepare(args) {
3263
3790
  const { cwd, bump, dryRun, versionFiles, ctx } = args;
3264
- const pkgPath = path13.join(cwd, "package.json");
3265
- if (!fs15.existsSync(pkgPath)) {
3791
+ const pkgPath = path16.join(cwd, "package.json");
3792
+ if (!fs18.existsSync(pkgPath)) {
3266
3793
  ctx.output.exitCode = 99;
3267
3794
  ctx.output.reason = "release prepare: package.json not found";
3268
3795
  return;
3269
3796
  }
3270
- const pkg = JSON.parse(fs15.readFileSync(pkgPath, "utf-8"));
3797
+ const pkg = JSON.parse(fs18.readFileSync(pkgPath, "utf-8"));
3271
3798
  if (typeof pkg.version !== "string") {
3272
3799
  ctx.output.exitCode = 99;
3273
3800
  ctx.output.reason = "release prepare: package.json has no version";
@@ -3338,8 +3865,8 @@ Merge this and then run \`kody2 release --mode finalize\`.`;
3338
3865
  }
3339
3866
  async function runFinalize(args) {
3340
3867
  const { cwd, dryRun, timeoutMs, releaseCfg, ctx } = args;
3341
- const pkgPath = path13.join(cwd, "package.json");
3342
- const pkg = JSON.parse(fs15.readFileSync(pkgPath, "utf-8"));
3868
+ const pkgPath = path16.join(cwd, "package.json");
3869
+ const pkg = JSON.parse(fs18.readFileSync(pkgPath, "utf-8"));
3343
3870
  if (typeof pkg.version !== "string") {
3344
3871
  ctx.output.exitCode = 99;
3345
3872
  ctx.output.reason = "release finalize: package.json has no version";
@@ -3606,6 +4133,25 @@ function tryPostPr3(prNumber, body, cwd) {
3606
4133
  }
3607
4134
  }
3608
4135
 
4136
+ // src/scripts/resolvePreviewUrl.ts
4137
+ var DEFAULT_PREVIEW_URL = "http://localhost:3000";
4138
+ var resolvePreviewUrl = async (ctx) => {
4139
+ const fromFlag = typeof ctx.args.previewUrl === "string" ? ctx.args.previewUrl.trim() : "";
4140
+ if (fromFlag.length > 0) {
4141
+ ctx.data.previewUrl = fromFlag;
4142
+ ctx.data.previewUrlSource = "flag";
4143
+ return;
4144
+ }
4145
+ const fromEnv = (process.env.PREVIEW_URL ?? "").trim();
4146
+ if (fromEnv.length > 0) {
4147
+ ctx.data.previewUrl = fromEnv;
4148
+ ctx.data.previewUrlSource = "env";
4149
+ return;
4150
+ }
4151
+ ctx.data.previewUrl = DEFAULT_PREVIEW_URL;
4152
+ ctx.data.previewUrlSource = "default";
4153
+ };
4154
+
3609
4155
  // src/scripts/reviewFlow.ts
3610
4156
  var reviewFlow = async (ctx) => {
3611
4157
  const prNumber = ctx.args.pr;
@@ -3990,7 +4536,7 @@ var watchStalePrsFlow = async (ctx) => {
3990
4536
  };
3991
4537
 
3992
4538
  // src/scripts/writeRunSummary.ts
3993
- import * as fs16 from "fs";
4539
+ import * as fs19 from "fs";
3994
4540
  var writeRunSummary = async (ctx, profile) => {
3995
4541
  const summaryPath = process.env.GITHUB_STEP_SUMMARY;
3996
4542
  if (!summaryPath) return;
@@ -4012,7 +4558,7 @@ var writeRunSummary = async (ctx, profile) => {
4012
4558
  if (reason) lines.push(`- **Reason:** ${reason}`);
4013
4559
  lines.push("");
4014
4560
  try {
4015
- fs16.appendFileSync(summaryPath, `${lines.join("\n")}
4561
+ fs19.appendFileSync(summaryPath, `${lines.join("\n")}
4016
4562
  `);
4017
4563
  } catch {
4018
4564
  }
@@ -4033,8 +4579,11 @@ var preflightScripts = {
4033
4579
  loadIssueContext,
4034
4580
  loadConventions,
4035
4581
  loadCoverageRules,
4582
+ loadQaGuide,
4036
4583
  buildSyntheticPlugin,
4037
4584
  resolveArtifacts,
4585
+ discoverQaContext,
4586
+ resolvePreviewUrl,
4038
4587
  composePrompt,
4039
4588
  skipAgent
4040
4589
  };
@@ -4167,9 +4716,9 @@ async function runExecutable(profileName, input) {
4167
4716
  data: {},
4168
4717
  output: { exitCode: 0 }
4169
4718
  };
4170
- const ndjsonDir = path14.join(input.cwd, ".kody2");
4719
+ const ndjsonDir = path17.join(input.cwd, ".kody2");
4171
4720
  const invokeAgent = async (prompt) => {
4172
- const externalPlugins = (profile.claudeCode.plugins ?? []).map((p) => path14.isAbsolute(p) ? p : path14.resolve(profile.dir, p)).filter((p) => p.length > 0);
4721
+ const externalPlugins = (profile.claudeCode.plugins ?? []).map((p) => path17.isAbsolute(p) ? p : path17.resolve(profile.dir, p)).filter((p) => p.length > 0);
4173
4722
  const syntheticPath = ctx.data.syntheticPluginPath;
4174
4723
  const pluginPaths = [...externalPlugins, ...syntheticPath ? [syntheticPath] : []];
4175
4724
  return runAgent({
@@ -4236,17 +4785,17 @@ async function runExecutable(profileName, input) {
4236
4785
  }
4237
4786
  }
4238
4787
  function resolveProfilePath(profileName) {
4239
- const here = path14.dirname(new URL(import.meta.url).pathname);
4788
+ const here = path17.dirname(new URL(import.meta.url).pathname);
4240
4789
  const candidates = [
4241
- path14.join(here, "executables", profileName, "profile.json"),
4790
+ path17.join(here, "executables", profileName, "profile.json"),
4242
4791
  // same-dir sibling (dev)
4243
- path14.join(here, "..", "executables", profileName, "profile.json"),
4792
+ path17.join(here, "..", "executables", profileName, "profile.json"),
4244
4793
  // up one (prod: dist/bin → dist/executables)
4245
- path14.join(here, "..", "src", "executables", profileName, "profile.json")
4794
+ path17.join(here, "..", "src", "executables", profileName, "profile.json")
4246
4795
  // fallback
4247
4796
  ];
4248
4797
  for (const c of candidates) {
4249
- if (fs17.existsSync(c)) return c;
4798
+ if (fs20.existsSync(c)) return c;
4250
4799
  }
4251
4800
  return candidates[0];
4252
4801
  }
@@ -4422,9 +4971,9 @@ function resolveAuthToken(env = process.env) {
4422
4971
  return token;
4423
4972
  }
4424
4973
  function detectPackageManager2(cwd) {
4425
- if (fs18.existsSync(path15.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
4426
- if (fs18.existsSync(path15.join(cwd, "yarn.lock"))) return "yarn";
4427
- if (fs18.existsSync(path15.join(cwd, "bun.lockb"))) return "bun";
4974
+ if (fs21.existsSync(path18.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
4975
+ if (fs21.existsSync(path18.join(cwd, "yarn.lock"))) return "yarn";
4976
+ if (fs21.existsSync(path18.join(cwd, "bun.lockb"))) return "bun";
4428
4977
  return "npm";
4429
4978
  }
4430
4979
  function shellOut(cmd, args, cwd, stream = true) {
@@ -4504,11 +5053,11 @@ function configureGitIdentity(cwd) {
4504
5053
  }
4505
5054
  function postFailureTail(issueNumber, cwd, reason) {
4506
5055
  if (!issueNumber) return;
4507
- const logPath = path15.join(cwd, ".kody2", "last-run.jsonl");
5056
+ const logPath = path18.join(cwd, ".kody2", "last-run.jsonl");
4508
5057
  let tail = "";
4509
5058
  try {
4510
- if (fs18.existsSync(logPath)) {
4511
- const content = fs18.readFileSync(logPath, "utf-8");
5059
+ if (fs21.existsSync(logPath)) {
5060
+ const content = fs21.readFileSync(logPath, "utf-8");
4512
5061
  tail = content.slice(-3e3);
4513
5062
  }
4514
5063
  } catch {
@@ -4533,7 +5082,7 @@ async function runCi(argv) {
4533
5082
  return 0;
4534
5083
  }
4535
5084
  const args = parseCiArgs(argv);
4536
- const cwd = args.cwd ? path15.resolve(args.cwd) : process.cwd();
5085
+ const cwd = args.cwd ? path18.resolve(args.cwd) : process.cwd();
4537
5086
  let earlyConfig;
4538
5087
  try {
4539
5088
  earlyConfig = loadConfig(cwd);
@@ -4666,9 +5215,9 @@ function parseChatArgs(argv, env = process.env) {
4666
5215
  return result;
4667
5216
  }
4668
5217
  function commitChatFiles(cwd, sessionId, verbose) {
4669
- const sessionFile = path16.relative(cwd, sessionFilePath(cwd, sessionId));
4670
- const eventsFile = path16.relative(cwd, eventsFilePath(cwd, sessionId));
4671
- const paths = [sessionFile, eventsFile].filter((p) => fs19.existsSync(path16.join(cwd, p)));
5218
+ const sessionFile = path19.relative(cwd, sessionFilePath(cwd, sessionId));
5219
+ const eventsFile = path19.relative(cwd, eventsFilePath(cwd, sessionId));
5220
+ const paths = [sessionFile, eventsFile].filter((p) => fs22.existsSync(path19.join(cwd, p)));
4672
5221
  if (paths.length === 0) return;
4673
5222
  const opts = { cwd, stdio: verbose ? "inherit" : "pipe" };
4674
5223
  try {
@@ -4706,7 +5255,7 @@ async function runChat(argv) {
4706
5255
  ${CHAT_HELP}`);
4707
5256
  return 64;
4708
5257
  }
4709
- const cwd = args.cwd ? path16.resolve(args.cwd) : process.cwd();
5258
+ const cwd = args.cwd ? path19.resolve(args.cwd) : process.cwd();
4710
5259
  const sessionId = args.sessionId;
4711
5260
  const unpackedSecrets = unpackAllSecrets();
4712
5261
  if (unpackedSecrets > 0) {
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "ui-review",
3
+ "describe": "UI/UX review of an open PR: browses the running preview with Playwright, compares behavior to diff intent, posts one structured review comment. Read-only on the repo (no commits); writes a throwaway Playwright spec under .kody2/.",
4
+ "kind": "oneshot",
5
+ "inputs": [
6
+ {
7
+ "name": "pr",
8
+ "flag": "--pr",
9
+ "type": "int",
10
+ "required": true,
11
+ "describe": "GitHub PR number to review."
12
+ },
13
+ {
14
+ "name": "previewUrl",
15
+ "flag": "--preview-url",
16
+ "type": "string",
17
+ "required": false,
18
+ "describe": "Base URL the agent should browse. Falls back to $PREVIEW_URL, then http://localhost:3000."
19
+ }
20
+ ],
21
+ "claudeCode": {
22
+ "model": "inherit",
23
+ "permissionMode": "acceptEdits",
24
+ "maxTurns": null,
25
+ "maxThinkingTokens": null,
26
+ "systemPromptAppend": null,
27
+ "tools": [
28
+ "Read",
29
+ "Grep",
30
+ "Glob",
31
+ "Bash",
32
+ "Write",
33
+ "Edit"
34
+ ],
35
+ "hooks": [],
36
+ "skills": [],
37
+ "commands": [],
38
+ "subagents": [],
39
+ "plugins": [],
40
+ "mcpServers": []
41
+ },
42
+ "cliTools": [
43
+ {
44
+ "name": "playwright",
45
+ "install": {
46
+ "required": false,
47
+ "checkCommand": "npx --no-install playwright --version",
48
+ "installCommand": "npx --yes playwright install --with-deps chromium"
49
+ },
50
+ "verify": "npx --no-install playwright --version",
51
+ "usage": "Use `npx playwright test <file>` to run a Playwright spec. Write ad-hoc specs under `.kody2/ui-review/*.spec.ts`. If `npx playwright test` errors with `Cannot find package '@playwright/test'`, install it once with `npm install -D @playwright/test` (or the repo's package-manager equivalent) before retrying — the `playwright` browser binaries are already set up by preflight, but the per-repo test framework may not be. Prefer `page.goto(process.env.UI_REVIEW_BASE_URL)` — the base URL is injected as `UI_REVIEW_BASE_URL` at run time. Capture screenshots with `await page.screenshot({ path: '.kody2/ui-review/<name>.png' })` and reference those paths in your final review.",
52
+ "allowedUses": [
53
+ "test",
54
+ "--version"
55
+ ]
56
+ }
57
+ ],
58
+ "inputArtifacts": [],
59
+ "outputArtifacts": [],
60
+ "scripts": {
61
+ "preflight": [
62
+ { "script": "reviewFlow" },
63
+ { "script": "loadTaskState" },
64
+ { "script": "loadConventions" },
65
+ { "script": "discoverQaContext" },
66
+ { "script": "loadQaGuide" },
67
+ { "script": "resolvePreviewUrl" },
68
+ { "script": "composePrompt" }
69
+ ],
70
+ "postflight": [
71
+ { "script": "postReviewResult" },
72
+ { "script": "saveTaskState" }
73
+ ]
74
+ }
75
+ }
@@ -0,0 +1,103 @@
1
+ You are Kody, a senior UI/UX reviewer. Review PR #{{pr.number}} by reading the diff AND browsing the running app with Playwright. Post ONE structured review comment. Do NOT edit any tracked source files. Do NOT run any `git` or `gh` commands.
2
+
3
+ You MAY write throwaway Playwright specs and screenshots under `.kody2/ui-review/` — that directory is ignored by the repo.
4
+
5
+ # PR #{{pr.number}}: {{pr.title}}
6
+
7
+ Base: {{pr.baseRefName}} ← Head: {{pr.headRefName}}
8
+
9
+ {{pr.body}}
10
+
11
+ # Preview URL
12
+
13
+ `{{previewUrl}}` (resolved from: {{previewUrlSource}})
14
+
15
+ Before you do anything else, run:
16
+
17
+ ```bash
18
+ curl -sS -o /dev/null -w "%{http_code}\n" --max-time 10 {{previewUrl}}
19
+ ```
20
+
21
+ If the response is not 2xx or 3xx, the preview is unreachable. In that case, SKIP browsing, note the failure in your review under "Browsing", and base your verdict on the diff alone.
22
+
23
+ # QA context (auto-discovered from the repo)
24
+
25
+ ```
26
+ {{qaContext}}
27
+ ```
28
+
29
+ # QA guide (committed in the repo — authoritative over the auto-discovery above)
30
+
31
+ {{qaGuide}}
32
+
33
+ # Diff
34
+
35
+ ```diff
36
+ {{prDiff}}
37
+ ```
38
+
39
+ {{conventionsBlock}}
40
+
41
+ {{toolsUsage}}
42
+
43
+ # What to do
44
+
45
+ 1. **Identify UI-affecting changes.** Read the diff. Which pages / components / forms / styles did this PR change? Which user-visible behavior should be verified in the browser? If the diff has no UI surface (pure backend, pure config, pure tests), say so and produce a diff-only review — do not spin up Playwright for nothing.
46
+
47
+ 2. **Plan the browse session.** For each UI-affecting change, pick 1–3 routes from the QA context that exercise it. If the change requires an authenticated role, grab credentials from the QA guide above. If no credentials are available for a role the change depends on, note that as a gap and browse only public pages.
48
+
49
+ 3. **Write a Playwright spec.** Create exactly one file at `.kody2/ui-review/browse.spec.ts`. Use `process.env.UI_REVIEW_BASE_URL` as the base URL. For each route you plan to check, write a test that:
50
+ - navigates there,
51
+ - performs the minimum interaction to exercise the change (click, submit, fill),
52
+ - takes a screenshot at `.kody2/ui-review/<slug>.png`,
53
+ - asserts at least one piece of visible content so the test fails loudly on a blank / error page.
54
+
55
+ Include a `playwright.config.ts` at `.kody2/ui-review/playwright.config.ts` only if you need custom config; otherwise rely on defaults (headless chromium).
56
+
57
+ 4. **Run it.** Invoke:
58
+
59
+ ```bash
60
+ UI_REVIEW_BASE_URL={{previewUrl}} npx playwright test .kody2/ui-review/browse.spec.ts --reporter=line
61
+ ```
62
+
63
+ Capture both stdout and exit code. If Playwright is not installed, the executor will have tried to install it in preflight — if it still fails, report the install error and fall back to a diff-only review.
64
+
65
+ 5. **Inspect screenshots.** Use the Read tool on each `.png` under `.kody2/ui-review/` so the visual state is in your context. Note anything that looks broken, empty, misaligned, or inconsistent with the diff's intent.
66
+
67
+ 6. **Write the review.** Your FINAL MESSAGE must be the markdown review comment — no preamble, no DONE / COMMIT_MSG markers. The entire final message is posted verbatim to the PR.
68
+
69
+ # Required output format
70
+
71
+ ```
72
+ ## Verdict: PASS | CONCERNS | FAIL
73
+
74
+ _UI review by kody2 — browsed {{previewUrl}}_
75
+
76
+ ### Summary
77
+ <2-3 sentences: what this PR changes in the UI, and whether the running app matches that intent>
78
+
79
+ ### What I browsed
80
+ - `<route>` — <what was checked, with screenshot path>
81
+ - ... (omit this section entirely if the diff had no UI surface)
82
+
83
+ ### UI findings
84
+ - <bullet — cite file:line for code issues; cite route + screenshot for visual issues; say "None." if truly none>
85
+
86
+ ### Code findings
87
+ - <bullets from reading the diff — correctness, a11y, performance, component structure; say "None." if none>
88
+
89
+ ### Gaps
90
+ - <anything you could NOT verify (missing creds, unreachable page, preview down) and why — say "None." if you verified everything relevant>
91
+
92
+ ### Bottom line
93
+ <one sentence>
94
+ ```
95
+
96
+ # Rules
97
+
98
+ - No commits. No `git` / `gh` invocations. No edits to files outside `.kody2/ui-review/`.
99
+ - Verdict **FAIL** only for clear visual regressions, broken flows, or correctness/accessibility issues that block merge.
100
+ - Verdict **CONCERNS** for clarity/polish/edge-case gaps that shouldn't block.
101
+ - Verdict **PASS** when the PR's UI changes work as intended and nothing obvious is broken.
102
+ - If the preview URL is unreachable, PASS/FAIL should be based on the diff alone, and the "Gaps" section must call that out.
103
+ - Be specific: every finding gets a route + screenshot reference, or a file:line reference. No generic advice.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kody-ade/kody-engine",
3
- "version": "0.2.47",
3
+ "version": "0.2.48",
4
4
  "description": "kody2 — autonomous development engine. Single-session Claude Code agent behind a generic executor + declarative executable profiles.",
5
5
  "license": "MIT",
6
6
  "type": "module",