diff --git a/.github/docs-sync/edit.mjs b/.github/docs-sync/edit.mjs index 15b89734ed..48a19a0c37 100644 --- a/.github/docs-sync/edit.mjs +++ b/.github/docs-sync/edit.mjs @@ -10,7 +10,8 @@ * wall-clock budget is recorded as action "pending" so the watermark holds * back and the next run re-collects those PRs. * - * Env: EDIT_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (set by workflow; read natively by the kilo provider). + * Env: EDIT_MODEL (provider/model), DOCS_SYNC_VARIANT (reasoning effort, default max), + * KILO_API_KEY + KILO_ORG_ID (set by workflow; read natively by the kilo provider). * Budgets: EDIT_BUDGET_MINUTES (default 50), EDIT_BATCH_TIMEOUT_MINUTES (default 15). * Test hook: DOCS_SYNC_BACKOFF_MS replaces every retry wait when set. */ @@ -18,7 +19,7 @@ import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" -import { backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs" +import { backoffMsForAttempt, deadline, remainingMs, REASONING_VARIANT, runKilo, sleepSync } from "./lib.mjs" import { readLearningsBlock } from "./learn.mjs" const BATCH_SIZE = 5 @@ -89,7 +90,7 @@ Batch specifics for this run: the PRs to handle are in the attached ${batchFile} "-m", model, "--variant", - "high", + REASONING_VARIANT, "--dir", process.cwd(), "-f", diff --git a/.github/docs-sync/learn.mjs b/.github/docs-sync/learn.mjs index 6baa59124e..99eb0aca20 100644 --- a/.github/docs-sync/learn.mjs +++ b/.github/docs-sync/learn.mjs @@ -9,7 +9,8 @@ * node learn.mjs — extraction: fetch corrections, call the model, validate * node learn.mjs --apply — apply: write learnings.json into LEARNINGS.md * - * Env: TRIAGE_MODEL (provider/model, reused), GH_TOKEN (or GITHUB_TOKEN). + * Env: TRIAGE_MODEL (provider/model, reused), DOCS_SYNC_VARIANT (reasoning effort, default max), + * GH_TOKEN (or GITHUB_TOKEN). * Budget: LEARNINGS_BUDGET_MINUTES (default 10). * Test hook: DOCS_SYNC_FIXTURE. When set to a fixture JSON path, skips every * GitHub API call and writes any marker PATCH to .patched instead of @@ -26,6 +27,8 @@ import fs from "node:fs" import path from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" +import { isSurfaceBranch } from "./surfaces.mjs" + const LEARNINGS_FILE = "packages/kilo-docs/LEARNINGS.md" const OUT_DIR = "docs-sync-out" const ATTEMPTS = 2 @@ -350,6 +353,26 @@ function git(args) { .trim() } +/** Split a learned-through `commit=` token into the individual surface tips. */ +function watermarkTips(commit) { + return String(commit ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean) +} + +/** The tip of a surface branch: its fetched origin ref, else the local ref, else null. */ +function branchTip(branch) { + for (const ref of [`origin/${branch}`, branch]) { + try { + return git(["rev-parse", "--verify", ref]) + } catch { + // try the next ref + } + } + return null +} + // --- main --- async function main() { @@ -400,221 +423,290 @@ async function extract() { patchFile = fixturePath + ".patched" } - const { api, repo, searchIssues, appendOutput, appendSummary, backoffMsForAttempt, runKilo, sleepSync } = - await import("./lib.mjs") - - let prData - let prBody = "" - let prNumber = "" - let branch = "" - + const { + api, + repo, + searchIssues, + appendOutput, + appendSummary, + backoffMsForAttempt, + REASONING_VARIANT, + runKilo, + sleepSync, + } = await import("./lib.mjs") + + // Step 1: resolve every open auto-docs surface PR. With one PR per surface, + // maintainer corrections live on any of them, so learn from all of them + // rather than a single rolling PR. + const targets = [] if (fixture) { // Fixture mode: skip all API calls. - prData = fixture.pr - prBody = prData.body ?? "" - prNumber = String(prData.number ?? 1) - branch = prData.head?.ref ?? "docs/auto-sync" + const list = Array.isArray(fixture.prs) ? fixture.prs : fixture.pr ? [fixture.pr] : [] + for (const pr of list) { + targets.push({ + index: targets.length, + prData: pr, + prBody: pr.body ?? "", + prNumber: String(pr.number ?? ""), + branch: pr.head?.ref ?? "docs/auto-sync", + comments: Array.isArray(pr.comments) ? pr.comments : list.length === 1 ? (fixture.comments ?? []) : [], + }) + } } else { - // Step 1: resolve the rolling PR. Use prepare-branch.mjs's selection rule so both - // target the same branch. searchIssues takes prs[0] with no author filter (like - // prepare-branch.mjs:69). But trust the body marker only when authored by - // github-actions[bot] (like watermark.mjs:35). The two rules differ on purpose: - // the branch must match what prepare-branch.mjs will check out, but a body is - // editable so its marker needs the author filter. const r = repo() - const prs = await searchIssues(`repo:${r} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 }) - if (prs.length === 0) { - log("no open rolling pull request — nothing to learn from") - - // Read existing learnings from main for empty-state artifacts. - let existing = [] + const prs = await searchIssues(`repo:${r} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 2 }) + for (const item of prs) { try { - const existingText = git(["show", `origin/main:${LEARNINGS_FILE}`]) - existing = parseLearnings(existingText) - } catch { - existing = [] + const detail = await api(`/repos/${r}/pulls/${item.number}`) + // Only a `docs/auto-sync/` head is one of this job's own PRs. + // A legacy dated head (`docs/auto-sync-`) and the bare integration + // ref are not learning targets: they are never counted and never PATCHed. + if (!isSurfaceBranch(detail.head?.ref)) { + log(`ignoring auto-docs PR #${item.number} on ${detail.head?.ref ?? "unknown"}: not a surface branch`) + continue + } + targets.push({ + index: targets.length, + prData: detail, + prBody: detail.body ?? "", + prNumber: String(detail.number), + branch: detail.head?.ref ?? "docs/auto-sync", + comments: null, + }) + } catch (err) { + warn(`could not read auto-docs PR #${item.number}: ${err.message}`) } - log(`no-PR existing entries from main: ${existing.length}`) - writeEmptyStateArtifacts(existing) - appendOutput("count", String(existing.length)) - appendSummary("### docs-sync learnings\n\nNo open auto-docs pull request; extraction skipped.") - return } - prData = await api(`/repos/${r}/pulls/${prs[0].number}`) - prBody = prData.body ?? "" - prNumber = String(prData.number) - branch = prData.head?.ref ?? "docs/auto-sync" } - // Step 2: read existing entries. + if (targets.length === 0) { + log("no open auto-docs pull request — nothing to learn from") + + // Read existing learnings from main for empty-state artifacts. + let existing = [] + try { + const existingText = git(["show", `origin/main:${LEARNINGS_FILE}`]) + existing = parseLearnings(existingText) + } catch { + existing = [] + } + log(`no-PR existing entries from main: ${existing.length}`) + writeEmptyStateArtifacts(existing) + appendOutput("count", String(existing.length)) + appendSummary("### docs-sync learnings\n\nNo open auto-docs pull request; extraction skipped.") + return + } + log(`open auto-docs PRs: ${targets.map((t) => `#${t.prNumber}`).join(", ")}`) + + // Step 2: read existing entries. Every surface branch carries the integration + // tree's copy of LEARNINGS.md, so the first branch is representative. let existing = [] let existingText = "" if (fixture) { existingText = readFileOrEmpty(LEARNINGS_FILE) - existing = parseLearnings(existingText) } else { try { - existingText = git(["show", `origin/${branch}:${LEARNINGS_FILE}`]) + existingText = git(["show", `origin/${targets[0].branch}:${LEARNINGS_FILE}`]) } catch { // branch copy absent — fall back to main, then empty. - // Required for the first live run: the rolling branch predates the seeded file. + // Required for the first live run: the surface branch predates the seeded file. try { existingText = git(["show", `origin/main:${LEARNINGS_FILE}`]) } catch { existingText = "" } } - existing = parseLearnings(existingText) } + existing = parseLearnings(existingText) log(`existing entries: ${existing.length}`) - // Replace the seed with the rolling-branch copy. Every step below can throw, and + // Replace the seed with the branch copy. Every step below can throw, and // these two files are all triage and edit read. writePromptArtifacts(existing) - // Step 3: parse marker. Trust only when authored by github-actions[bot] (like watermark.mjs:35). - let commitWm = null - let commentWm = null - const trusted = prData.user?.login === "github-actions[bot]" - if (trusted) { - ;({ commit: commitWm, comment: commentWm } = parseLearnedThrough(prBody)) - } else { - log("PR author is not github-actions[bot]; ignoring body marker") - } - log(`watermark: commit=${commitWm ?? "none"} comment=${commentWm ?? "none"}`) - - // Step 4: fetch and tip SHA. - let tipSha - if (fixture) { - tipSha = git(["rev-parse", "HEAD"]) - } else { - git(["fetch", "origin", "main", branch]) - tipSha = git(["rev-parse", `origin/${branch}`]) - } - - // Step 5: candidate commits. - let rangeArgs = [`origin/main..origin/${branch}`] - if (fixture) { - // In fixture mode, work from the local repo state. - try { - git(["rev-parse", "--verify", branch]) - rangeArgs = [`origin/main..${branch}`] - } catch { - rangeArgs = [`origin/main..HEAD`] + // Step 3: parse each PR's marker. Trust only when authored by + // github-actions[bot] (like watermark.mjs:35). A marker holds the tips of every + // surface branch learned so far, comma-separated. + for (const t of targets) { + if (t.prData.user?.login !== "github-actions[bot]") { + log(`PR #${t.prNumber} author is not github-actions[bot]; ignoring body marker`) + t.tips = [] + t.commentWm = null + continue } + const { commit, comment } = parseLearnedThrough(t.prBody) + t.tips = watermarkTips(commit) + t.commentWm = comment } - - if (commitWm) { - let wmExists = false - try { - git(["cat-file", "-e", `${commitWm}^{commit}`]) - wmExists = true - } catch { - wmExists = false - } - if (wmExists) { - rangeArgs.push(`^${commitWm}`) + // The union is safe: a tip that is not an ancestor of a branch removes + // nothing from that branch's range. + const tips = [...new Set(targets.flatMap((t) => t.tips ?? []))] + log(`watermark tips: ${tips.length > 0 ? tips.map((s) => s.slice(0, 7)).join(", ") : "none"}`) + + // Step 4: fetch every surface branch and resolve its tip. + const branches = [...new Set(targets.map((t) => t.branch))] + if (!fixture) { + for (const b of branches) { + try { + git(["fetch", "origin", "main", b]) + } catch (err) { + warn(`could not fetch ${b}: ${err.message}`) + } } - // A missing watermark commit (force-push, rebase) drops the exclusion. - // The duplicate-rule-text rejection in validateDelta blocks the re-added duplicate. } + for (const t of targets) { + t.tip = branchTip(t.branch) + if (!t.tip) warn(`could not resolve a tip for ${t.branch}; skipping PR #${t.prNumber}`) + } + const currentTips = [...new Set(targets.map((t) => t.tip).filter(Boolean))] + const learnedCommit = currentTips.join(",") || null - const logOut = git(["log", "--no-merges", "--format=%H|%ae|%cI|%s", ...rangeArgs]) - const rawCommits = logOut ? logOut.split("\n").filter(Boolean) : [] - + // Step 5: candidate commits from every surface branch. const botEmail = "41898282+github-actions[bot]@users.noreply.github.com" const candidates = [] const candidateSources = [] const deletedInWindow = [] + const byTarget = new Map() + const seenSha = new Set() - for (const line of rawCommits) { - const [sha, email, dateIso] = line.split("|") - // Drop commits authored by the sync job itself (criterion 5). - if (email === botEmail) continue - // Everything reachable from main is already excluded by the range (criterion 6). - - // Get the full file list. - let files = [] - try { - const out = git(["show", "--name-only", "--format=", sha]) - files = out - ? out - .split("\n") - .filter(Boolean) - .filter((f) => f) - : [] - } catch { + for (const t of targets) { + const list = [] + if (!t.tip) { + byTarget.set(t.index, list) continue } + // In fixture mode, work from the local repo state when the origin ref is absent. + let rangeArgs = [`origin/main..origin/${t.branch}`] + if (fixture) { + try { + git(["rev-parse", "--verify", t.branch]) + rangeArgs = [`origin/main..${t.branch}`] + } catch { + rangeArgs = [`origin/main..HEAD`] + } + } + for (const tip of tips) { + try { + git(["cat-file", "-e", `${tip}^{commit}`]) + rangeArgs.push(`^${tip}`) + } catch { + // A missing watermark commit (force-push, rebase) drops the exclusion. + // The duplicate-rule-text rejection in validateDelta blocks a re-added duplicate. + } + } - // Get the docs-scoped diff and message. - let message = "" - let docDiff = "" + let rawCommits = [] try { - message = git(["show", "--format=%B", "--no-patch", sha]).trim() - docDiff = git(["show", "--format=", sha, "--", "packages/kilo-docs"]) - // Cap diff sizes. - if (docDiff.length > 20000) docDiff = docDiff.slice(0, 20000) + "\n[truncated]" - } catch { - // skip on error + const logOut = git(["log", "--no-merges", "--format=%H|%ae|%cI|%s", ...rangeArgs]) + rawCommits = logOut ? logOut.split("\n").filter(Boolean) : [] + } catch (err) { + warn(`could not read commits for ${t.branch}: ${err.message}`) + byTarget.set(t.index, list) + continue } - // Drop commits whose docs-scoped diff is empty. - if (!docDiff.trim()) continue + for (const line of rawCommits) { + const [sha, email, dateIso] = line.split("|") + // Drop commits authored by the sync job itself (criterion 5). + if (email === botEmail) continue + if (seenSha.has(sha)) continue + // Everything reachable from main is already excluded by the range (criterion 6). + seenSha.add(sha) - // Collect deleted rule lines from LEARNINGS.md. - for (const dl of docDiff.split("\n")) { - if (!dl.startsWith("-")) continue - const stripped = dl.slice(1).trim() - const parsed = stripped.match(LINE_RE) - if (parsed) { - deletedInWindow.push(clean(parsed.groups.rule).replaceAll("\n", " ")) + // Get the full file list. + let files = [] + try { + const out = git(["show", "--name-only", "--format=", sha]) + files = out + ? out + .split("\n") + .filter(Boolean) + .filter((f) => f) + : [] + } catch { + continue } - } - // Cap total diff data. - const totalDiff = candidates.reduce((n, c) => n + (c.diff ? c.diff.length : 0), 0) - if (totalDiff > 120000) { - log(`diff cap reached at commit ${sha.slice(0, 7)}; truncating`) - candidates.push({ + // Get the docs-scoped diff and message. + let message = "" + let docDiff = "" + try { + message = git(["show", "--format=%B", "--no-patch", sha]).trim() + docDiff = git(["show", "--format=", sha, "--", "packages/kilo-docs"]) + // Cap diff sizes. + if (docDiff.length > 20000) docDiff = docDiff.slice(0, 20000) + "\n[truncated]" + } catch { + // skip on error + } + + // Drop commits whose docs-scoped diff is empty. + if (!docDiff.trim()) continue + + // Collect deleted rule lines from LEARNINGS.md. + for (const dl of docDiff.split("\n")) { + if (!dl.startsWith("-")) continue + const stripped = dl.slice(1).trim() + const parsed = stripped.match(LINE_RE) + if (parsed) { + deletedInWindow.push(clean(parsed.groups.rule).replaceAll("\n", " ")) + } + } + + // Cap total diff data. + const totalDiff = candidates.reduce((n, c) => n + (c.diff ? c.diff.length : 0), 0) + if (totalDiff > 120000) { + log(`diff cap reached at commit ${sha.slice(0, 7)}; truncating`) + const capped = { + source: `commit:${sha.slice(0, 7)}`, + iso: dateIso, + date: dateIso.slice(0, 10), + message, + files, + diff: "[truncated]", + } + candidates.push(capped) + list.push(capped) + candidateSources.push(`commit:${sha.slice(0, 7)}`) + break + } + + const cand = { source: `commit:${sha.slice(0, 7)}`, iso: dateIso, date: dateIso.slice(0, 10), message, files, - diff: "[truncated]", - }) + diff: docDiff, + } + candidates.push(cand) + list.push(cand) candidateSources.push(`commit:${sha.slice(0, 7)}`) - break } - - candidates.push({ - source: `commit:${sha.slice(0, 7)}`, - iso: dateIso, - date: dateIso.slice(0, 10), - message, - files, - diff: docDiff, - }) - candidateSources.push(`commit:${sha.slice(0, 7)}`) + byTarget.set(t.index, list) } - // Step 6: candidate comments. + // Step 6: candidate comments from every surface PR. let allComments = [] - let maxCommentAt = "none" - - if (fixture && fixture.comments) { - allComments = fixture.comments - } else if (prNumber) { - const pages = [] - for (let page = 1; page <= 5; page++) { - const batch = await api(`/repos/${repo()}/pulls/${prNumber}/comments?per_page=100&page=${page}`) - pages.push(...batch) - if (batch.length < 100) break + if (fixture) { + for (const t of targets) { + t.rawComments = t.comments ?? [] + allComments.push(...t.rawComments) + } + } else { + for (const t of targets) { + if (!t.prNumber) continue + const pages = [] + for (let page = 1; page <= 5; page++) { + const batch = await api(`/repos/${repo()}/pulls/${t.prNumber}/comments?per_page=100&page=${page}`) + pages.push(...batch) + if (batch.length < 100) break + } + t.rawComments = pages + allComments.push(...pages) } - allComments = pages } + let maxCommentAt = "none" if (allComments.length > 0) { let max = "" for (const c of allComments) { @@ -623,44 +715,44 @@ async function extract() { maxCommentAt = max || "none" } - // Filter trusted comments. - const trustedComments = allComments.filter((c) => { - if (!isTrustedComment(c)) return false - if (commentWm && c.created_at <= commentWm) return false - return true - }) - - // Step 7: correlate comments to commits. + // Step 7: correlate each PR's trusted comments to that PR's own commits. // A comment is a commit's trigger when c.path is in that commit's full file list // and c.created_at < commit date. The earliest such commit claims it. // Compare parsed timestamps so different timezone offsets do not skew the ordering. - for (const c of trustedComments) { - let best = null - const cTime = Date.parse(c.created_at) - for (const cc of candidates) { - if (!Array.isArray(cc.files) || !cc.files.includes(c.path)) continue - const ccTime = Date.parse(cc.iso) - if (cTime < ccTime) { - if (!best || ccTime < Date.parse(best.iso)) { - best = cc + for (const t of targets) { + const trustedComments = (t.rawComments ?? []).filter((c) => { + if (!isTrustedComment(c)) return false + if (t.commentWm && c.created_at <= t.commentWm) return false + return true + }) + for (const c of trustedComments) { + let best = null + const cTime = Date.parse(c.created_at) + for (const cc of byTarget.get(t.index) ?? []) { + if (!Array.isArray(cc.files) || !cc.files.includes(c.path)) continue + const ccTime = Date.parse(cc.iso) + if (cTime < ccTime) { + if (!best || ccTime < Date.parse(best.iso)) { + best = cc + } } } - } - if (best) { - best.comment = { - author_association: c.author_association, - path: c.path, - body: capBody(c.body), + if (best) { + best.comment = { + author_association: c.author_association, + path: c.path, + body: capBody(c.body), + } + } else { + candidates.push({ + source: `comment:${c.id}`, + date: (c.created_at ?? "").slice(0, 10), + path: c.path, + body: capBody(c.body), + author_association: c.author_association, + }) + candidateSources.push(`comment:${c.id}`) } - } else { - candidates.push({ - source: `comment:${c.id}`, - date: (c.created_at ?? "").slice(0, 10), - path: c.path, - body: capBody(c.body), - author_association: c.author_association, - }) - candidateSources.push(`comment:${c.id}`) } } @@ -675,8 +767,8 @@ async function extract() { `### docs-sync learnings\n\nNo new candidate corrections. Entries: ${existing.length}. Marker route: empty (no candidates).`, ) - const marker = renderLearnedThrough({ commit: tipSha, comment: maxCommentAt }) - await patchOrLogMarker({ prBody, prNumber, marker, fixture, patchFile }) + const marker = renderLearnedThrough({ commit: learnedCommit, comment: maxCommentAt }) + await patchOrLogMarker({ targets, marker, fixture, patchFile }) return } @@ -712,7 +804,7 @@ async function extract() { } const result = runKilo({ - args: ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", inputFile], + args: ["run", prompt, "-m", model, "--variant", REASONING_VARIANT, "--dir", process.cwd(), "-f", inputFile], timeoutMs: Math.min(EXTRACTION_TIMEOUT_MS, left), streamStdout: false, label: "learnings extraction", @@ -766,7 +858,7 @@ async function extract() { // Non-empty validated delta. const newEntries = applyDelta(existing, { add: validated.add, remove: validated.remove }) fs.writeFileSync(`${OUT_DIR}/learnings.json`, JSON.stringify(newEntries, null, 2)) - const marker = renderLearnedThrough({ commit: tipSha, comment: maxCommentAt }) + const marker = renderLearnedThrough({ commit: learnedCommit, comment: maxCommentAt }) const suppressed = process.env.DRY_RUN === "true" || process.env.LEARNINGS_NO_PATCH === "1" if (!suppressed) appendOutput("learned_through", marker) if (suppressed) log(`learned-through output suppressed: ${marker}`) @@ -790,8 +882,8 @@ async function extract() { writePromptArtifacts(existing) appendOutput("count", String(existing.length)) - const marker = renderLearnedThrough({ commit: tipSha, comment: maxCommentAt }) - await patchOrLogMarker({ prBody, prNumber, marker, fixture, patchFile }) + const marker = renderLearnedThrough({ commit: learnedCommit, comment: maxCommentAt }) + await patchOrLogMarker({ targets, marker, fixture, patchFile }) const rejected = validated.rejected.length appendSummary( @@ -827,7 +919,7 @@ function readFileOrEmpty(file) { } } -async function patchOrLogMarker({ prBody, prNumber, marker, fixture, patchFile }) { +async function patchOrLogMarker({ targets, marker, fixture, patchFile }) { const suppressed = process.env.DRY_RUN === "true" || process.env.LEARNINGS_NO_PATCH === "1" if (suppressed) { @@ -846,23 +938,32 @@ async function patchOrLogMarker({ prBody, prNumber, marker, fixture, patchFile } } // Live PATCH: body-only, one line changed. The job already holds pull-requests: write. - // Re-read the body first. The body in hand was fetched before the extraction call, so + // Re-read each body first. The body in hand was fetched before the extraction call, so // patching that copy would drop any edit made in the minutes since. GitHub has no // conditional update for a pull request body, so a short fetch-to-PATCH race remains. const { api, repo } = await import("./lib.mjs") - let latestBody = prBody - try { - const fresh = await api(`/repos/${repo()}/pulls/${prNumber}`) - latestBody = fresh.body ?? "" - } catch (err) { - warn(`could not re-read PR #${prNumber} before the marker PATCH: ${err.message}. Using the earlier body.`) - } - const newBody = patchMarkerIntoBody(latestBody, marker) - await api(`/repos/${repo()}/pulls/${prNumber}`, { - method: "PATCH", - body: { body: newBody }, - }) - log(`PATCHed learned-through marker on PR #${prNumber}`) + for (const t of targets) { + let latestBody = t.prBody + try { + const fresh = await api(`/repos/${repo()}/pulls/${t.prNumber}`) + latestBody = fresh.body ?? "" + } catch (err) { + warn(`could not re-read PR #${t.prNumber} before the marker PATCH: ${err.message}. Using the earlier body.`) + } + const newBody = patchMarkerIntoBody(latestBody, marker) + try { + await api(`/repos/${repo()}/pulls/${t.prNumber}`, { + method: "PATCH", + body: { body: newBody }, + }) + log(`PATCHed learned-through marker on PR #${t.prNumber}`) + } catch (err) { + // One target's failed PATCH must not skip the remaining targets. On the + // next run a stale marker only re-reads a range already learned from, and + // the duplicate-rule-text rejection blocks a re-added rule. + warn(`could not PATCH the learned-through marker on PR #${t.prNumber}: ${err.message}`) + } + } } // --- entry point --- diff --git a/.github/docs-sync/lib.mjs b/.github/docs-sync/lib.mjs index f63155adf6..e10f4564c0 100644 --- a/.github/docs-sync/lib.mjs +++ b/.github/docs-sync/lib.mjs @@ -14,6 +14,11 @@ import fs from "node:fs" const API = process.env.DOCS_SYNC_API_BASE || "https://api.github.com" const MAX_RETRIES = 3 +// Reasoning effort passed to every `kilo run` as `--variant`. The workflow sets +// DOCS_SYNC_VARIANT (default "max"); scripts fall back to max so a local run or a +// caller that forgets the env still gets the intended effort. +export const REASONING_VARIANT = process.env.DOCS_SYNC_VARIANT || "max" + export function token() { const t = process.env.GH_TOKEN || process.env.GITHUB_TOKEN if (!t) throw new Error("GH_TOKEN (or GITHUB_TOKEN) is required") @@ -28,14 +33,16 @@ export function repo() { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) -export async function api(path, { method = "GET", body } = {}) { +export async function api(path, { method = "GET", body, auth } = {}) { for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { let res try { res = await fetch(`${API}${path}`, { method, headers: { - authorization: `Bearer ${token()}`, + // `auth` names a token for a repository this job does not own (the + // cloud repo); callers that omit it authenticate as this repo. + authorization: `Bearer ${auth ?? token()}`, accept: "application/vnd.github+json", "x-github-api-version": "2022-11-28", "user-agent": "kilo-docs-sync-bot", diff --git a/.github/docs-sync/model-config.test.mjs b/.github/docs-sync/model-config.test.mjs new file mode 100644 index 0000000000..a4543c4caa --- /dev/null +++ b/.github/docs-sync/model-config.test.mjs @@ -0,0 +1,166 @@ +// kilocode_change - new file + +/** + * Guards the docs-sync model + reasoning-effort wiring. + * + * The bot runs DeepSeek V4.1 Flash at maximum reasoning effort on every LLM + * call. The id and effort are easy to drift (a hand-edited workflow env, a new + * `kilo run` call site that forgets `--variant`), so this ordinary unit test + * asserts the wiring from the source of truth: the workflow env defaults, the + * shared `REASONING_VARIANT` constant, and every `args` array passed to + * `runKilo` in triage.mjs, edit.mjs, learn.mjs, plus the workflow's fix step. + * + * Run: node .github/docs-sync/model-config.test.mjs + */ + +import assert from "node:assert/strict" +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.resolve(HERE, "..", "..") +const read = (p) => fs.readFileSync(path.join(ROOT, p), "utf8") + +const MODEL = "kilo/deepseek/deepseek-v4.1-flash" +const MAX = "max" + +const workflow = read(".github/workflows/docs-sync.yml") +const lib = read(".github/docs-sync/lib.mjs") + +const failures = [] +function check(label, fn) { + try { + fn() + console.log(`ok - ${label}`) + } catch (err) { + failures.push(`${label}: ${err.message}`) + console.error(`not ok - ${label}: ${err.message}`) + } +} + +/** Every `args: [ ... ]` array literal passed to a `runKilo(` call, as source text. */ +function runKiloArgArrays(src) { + const arrays = [] + const marker = "runKilo(" + let i = src.indexOf(marker) + while (i !== -1) { + const argsIdx = src.indexOf("args:", i) + const start = src.indexOf("[", argsIdx) + assert.ok(argsIdx !== -1 && start !== -1, "runKilo call without an args array") + let depth = 0 + let j = start + for (; j < src.length; j++) { + if (src[j] === "[") depth++ + else if (src[j] === "]") { + depth-- + if (depth === 0) break + } + } + arrays.push(src.slice(start, j + 1)) + i = src.indexOf(marker, j) + } + return arrays +} + +/** The workflow step block whose `- name:` line starts with `prefix`. */ +function stepBlock(prefix) { + const blocks = workflow.split("\n - name: ") + return blocks.find((b) => b.startsWith(prefix)) ?? null +} + +// 1. Model defaults: DeepSeek 4.1 Flash via the kilo provider, vars. override kept. +for (const [key, varName] of [ + ["TRIAGE_MODEL", "DOCS_SYNC_TRIAGE_MODEL"], + ["EDIT_MODEL", "DOCS_SYNC_EDIT_MODEL"], +]) { + check(`${key} defaults to ${MODEL} with a vars.${varName} override`, () => { + const re = new RegExp(`${key}:\\s*\\$\\{\\{\\s*vars\\.${varName}\\s*\\|\\|\\s*'([^']+)'\\s*\\}\\}`) + const m = workflow.match(re) + assert.ok(m, `${key} env line with a vars.${varName} override was not found`) + assert.equal(m[1], MODEL, `${key} default is ${m[1]}, expected ${MODEL}`) + }) +} + +// 2. The effort is overridable but defaults to max. +check(`DOCS_SYNC_VARIANT defaults to "${MAX}"`, () => { + assert.match(workflow, /^\s*DOCS_SYNC_VARIANT:\s*"max"\s*$/m, 'top-level env DOCS_SYNC_VARIANT is not "max"') +}) + +// 2b. The comment above it cites where the id and variants come from, so a +// future id/effort change can be re-verified against the live list. +check("workflow comment cites the Kilo gateway model list", () => { + assert.match(workflow, /api\.kilo\.ai\/api\/openrouter\/models/, "the model source URL is not cited in the workflow") +}) + +// 3. One shared constant so all call sites agree. +check("lib.mjs exports REASONING_VARIANT defaulting to max", () => { + assert.match( + lib, + /export const REASONING_VARIANT = process\.env\.DOCS_SYNC_VARIANT \|\| "max"/, + "REASONING_VARIANT constant not found or not defaulting to max", + ) +}) + +// 3b. The constant actually resolves to max when the workflow env is absent. +delete process.env.DOCS_SYNC_VARIANT +const { REASONING_VARIANT } = await import("./lib.mjs") +check(`REASONING_VARIANT resolves to "${MAX}" by default`, () => { + assert.equal(REASONING_VARIANT, MAX) +}) + +// 4. Every script's `kilo run` argv carries -m and --variant REASONING_VARIANT. +for (const file of ["triage.mjs", "edit.mjs", "learn.mjs"]) { + const src = read(`.github/docs-sync/${file}`) + const arrays = runKiloArgArrays(src) + + check(`${file} has at least one runKilo call`, () => { + assert.ok(arrays.length > 0, "no runKilo call found") + }) + + arrays.forEach((args, index) => { + check(`${file} runKilo args #${index + 1} pass -m and --variant REASONING_VARIANT`, () => { + assert.ok(args.includes('"run"'), `argv does not start the run command: ${args}`) + assert.ok(args.includes('"-m"'), `argv is missing -m: ${args}`) + assert.ok(args.includes('"--variant"'), `argv is missing --variant: ${args}`) + assert.ok(args.includes("REASONING_VARIANT"), `argv must pass --variant REASONING_VARIANT: ${args}`) + }) + }) +} + +// 5. The deliberate --auto difference is preserved: triage/edit grant bash to the +// agent; the learnings extraction stays tool-free (learn.mjs:694-697). +for (const file of ["triage.mjs", "edit.mjs"]) { + check(`${file} keeps --auto`, () => { + const arrays = runKiloArgArrays(read(`.github/docs-sync/${file}`)) + for (const args of arrays) assert.ok(args.includes('"--auto"'), `argv is missing --auto: ${args}`) + }) +} +check("learn.mjs stays without --auto", () => { + const arrays = runKiloArgArrays(read(".github/docs-sync/learn.mjs")) + for (const args of arrays) assert.ok(!args.includes('"--auto"'), `learn.mjs must not pass --auto: ${args}`) +}) + +// 6. The workflow's fix step passes the same effort as the scripts. +check('workflow fix step passes --variant "$DOCS_SYNC_VARIANT"', () => { + const block = stepBlock("Fix verify failures") + assert.ok(block, "Fix verify failures step not found") + assert.ok(block.includes("kilo run"), "fix step no longer runs `kilo run`") + assert.ok(block.includes('--variant "$DOCS_SYNC_VARIANT"'), 'fix step is missing --variant "$DOCS_SYNC_VARIANT"') +}) + +// 7. Every `kilo run` in the workflow lives in a step that also passes --variant. +check("every workflow kilo run passes --variant", () => { + const runs = workflow.split("\n - name: ").filter((b) => /(^|\s)kilo run(\s|$)/m.test(b)) + assert.ok(runs.length > 0, "no `kilo run` found in the workflow") + for (const block of runs) { + const name = block.split("\n")[0] + assert.ok(block.includes("--variant"), `step "${name}" runs kilo without --variant`) + } +}) + +if (failures.length > 0) { + console.error(`\n${failures.length} check(s) failed`) + process.exit(1) +} +console.log("\nmodel-config: all checks passed") diff --git a/.github/docs-sync/prepare-branch.mjs b/.github/docs-sync/prepare-branch.mjs index 2710832ccf..389d3c966e 100644 --- a/.github/docs-sync/prepare-branch.mjs +++ b/.github/docs-sync/prepare-branch.mjs @@ -1,19 +1,31 @@ // kilocode_change - new file /** - * Prepares the rolling docs-sync branch before the edit pass: - * - an open auto-docs PR exists -> check out its head branch and merge - * origin/main (preserves any human commits on the branch) - * - otherwise -> fresh branch from origin/main (bot force-pushes later) + * Prepares the rolling docs-sync integration branch before the edit pass. * - * Outputs: branch, mode (update|fresh|conflict), pr_number (empty when fresh). + * The integration branch is always `DEFAULT_BRANCH` (`docs/auto-sync`) and + * carries the accumulating tree; it has no PR of its own. This step: + * - checks it out (fetching it when it exists on origin, else creating it + * from origin/main) and merges origin/main via mergeOrFallback + * - merges each open per-surface `docs/auto-sync/` PR branch + * (best effort; a per-branch conflict is aborted and skipped) + * + * Legacy rolling PRs are ignored completely: a dated branch like + * `docs/auto-sync-2026-09-11` is neither a surface branch nor an integration + * base, so this step never comments on, closes, or reuses one. + * + * Outputs: branch, mode (update|fresh|conflict). */ import { execFileSync } from "node:child_process" import { pathToFileURL } from "node:url" import { api, appendOutput, repo, searchIssues } from "./lib.mjs" +import { isSurfaceBranch, surfaceNameFromBranch } from "./surfaces.mjs" export const DEFAULT_BRANCH = "docs/auto-sync" +// Git refs cannot hold both `docs/auto-sync` and `docs/auto-sync/`, so +// the integration tree is pushed to this sibling durability ref instead. +export const INTEGRATION_BRANCH = "docs/auto-sync-integration" const defaultGit = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim() @@ -64,36 +76,129 @@ export function mergeOrFallback({ branch, git = defaultGit }) { } } +/** + * Best-effort merge of one surface branch into the integration branch. The + * fetched ref lands under `refs/docs-sync/surfaces/` so it cannot collide with + * the integration branch's own remote-tracking ref (`docs/auto-sync` may not be + * a path prefix of `docs/auto-sync/`). + */ +function mergeSurface(git, ref, name) { + const local = `refs/docs-sync/surfaces/${name}` + try { + git(["fetch", "origin", `+refs/heads/${ref}:${local}`]) + git(["merge", local, "--no-edit"]) + return true + } catch (err) { + let unmerged = "" + try { + unmerged = git(["ls-files", "--unmerged"]) + } catch { + // ls-files failing is not a conflict signal + } + let inProgress = false + try { + git(["rev-parse", "-q", "--verify", "MERGE_HEAD"]) + inProgress = true + } catch { + inProgress = false + } + if (unmerged.length > 0 || inProgress) { + try { + git(["merge", "--abort"]) + } catch (abortErr) { + console.warn(`::warning::docs-sync: could not abort merge of ${ref}: ${abortErr.message}`) + } + } + console.warn(`::warning::docs-sync: could not merge ${ref} into the integration branch: ${err.message}`) + return false + } +} + async function main() { const git = defaultGit - const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 }) + // Page or two covers one PR per surface. + const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 2 }) + + const details = [] + for (const pr of prs) { + try { + details.push(await api(`/repos/${repo()}/pulls/${pr.number}`)) + } catch (err) { + console.warn(`::warning::docs-sync: could not read auto-docs PR #${pr.number}: ${err.message}`) + } + } + + // Only `docs/auto-sync/` heads are this job's own PRs: a legacy + // dated branch (`docs/auto-sync-`) and the bare integration ref are not. + const surfacePrs = details.filter((pr) => isSurfaceBranch(pr?.head?.ref)) - let mode = "fresh" - let prNumber = "" let branch = DEFAULT_BRANCH + let mode = "fresh" - if (prs.length > 0) { - const pr = await api(`/repos/${repo()}/pulls/${prs[0].number}`) - branch = pr.head?.ref ?? DEFAULT_BRANCH - prNumber = String(pr.number) - git(["fetch", "origin", "main", branch]) - git(["checkout", branch]) - ;({ branch, mode } = mergeOrFallback({ branch, git })) - } else { - // Keep the remote-tracking ref current so the later --force-with-lease - // push (stale branch left over from a merged/closed PR) is safe. + // Integration base order: the durability ref, then the bare `docs/auto-sync` + // integration ref, then a fresh checkout of origin/main. A dated legacy + // branch is never one of these. + let base = null + const bases = [ + { remote: INTEGRATION_BRANCH, local: INTEGRATION_BRANCH }, + { remote: DEFAULT_BRANCH, local: `${DEFAULT_BRANCH}-legacy` }, + ] + for (const candidate of bases) { + if (base) break try { - git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]) + git(["fetch", "origin", `+refs/heads/${candidate.remote}:refs/remotes/origin/${candidate.local}`]) + base = `origin/${candidate.local}` } catch { - console.log(`branch ${branch} does not exist on origin yet; will create it on push`) + console.log(`branch ${candidate.remote} does not exist on origin yet`) + } + } + + if (base) { + git(["checkout", "-B", DEFAULT_BRANCH, base]) + ;({ branch, mode } = mergeOrFallback({ branch: DEFAULT_BRANCH, git })) + } else { + git(["checkout", "-B", DEFAULT_BRANCH, "origin/main"]) + } + + // Human commits on a surface PR must stay in the integration tree. A single + // per-branch conflict warns and continues — it must never fail the run. + for (const pr of surfacePrs) { + const ref = String(pr.head?.ref ?? "") + const name = surfaceNameFromBranch(ref) ?? "unknown" + if (mergeSurface(git, ref, name)) console.log(`merged ${ref} into ${branch}`) + } + + // A ref may not be a path prefix of another ref, so `docs/auto-sync` must be + // gone for `docs/auto-sync/` to exist. Its content already lives in + // the integration base and the per-surface branches. Delete it whether or not + // an open legacy PR was found: a lingering branch (a closed PR, a partial + // cleanup) still blocks every surface push. + try { + const stale = git(["ls-remote", "--heads", "origin", DEFAULT_BRANCH]) + .split("\n") + .some((line) => line.endsWith(`refs/heads/${DEFAULT_BRANCH}`)) + if (stale) { + try { + git(["push", "origin", "--delete", DEFAULT_BRANCH]) + } catch (err) { + console.warn(`::warning::docs-sync: could not delete the legacy branch ${DEFAULT_BRANCH}: ${err.message}`) + } } - git(["checkout", "-B", branch, "origin/main"]) + } catch (err) { + console.warn(`::warning::docs-sync: could not check for the legacy branch ${DEFAULT_BRANCH}: ${err.message}`) + } + // Drop the stale remote-tracking ref too, otherwise fetching or pushing a + // `docs/auto-sync/` branch later in the run fails on the + // directory/file ref conflict. Deleting a ref that is not present is a no-op. + try { + git(["update-ref", "-d", `refs/remotes/origin/${DEFAULT_BRANCH}`]) + } catch (err) { + console.warn(`::warning::docs-sync: could not remove the stale ref for ${DEFAULT_BRANCH}: ${err.message}`) } appendOutput("branch", branch) appendOutput("mode", mode) - appendOutput("pr_number", prNumber) - console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`) + console.log(`branch ${branch} ready (mode=${mode}, surfacePrs=${surfacePrs.length})`) } const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href diff --git a/.github/docs-sync/reviewers.mjs b/.github/docs-sync/reviewers.mjs new file mode 100644 index 0000000000..31377cc9c1 --- /dev/null +++ b/.github/docs-sync/reviewers.mjs @@ -0,0 +1,203 @@ +// kilocode_change - new file + +/** + * Reviewer ranking for the docs-sync surface PRs. + * + * `rankContributors` is pure: it turns commit metadata into a recency-weighted + * leaderboard. `computeSurfaceReviewers` is the side-effecting wrapper that + * reads the committed surface map, asks the GitHub API for commits touching a + * surface's source prefixes, then asks for the top candidates' permission until + * two people with write access are found. Both are dependency-free so the + * workflow needs no extra package. + */ + +import { + SURFACE_MAP_PATH, + OTHER, + loadSurfaceMap, + surfaceReviewers, + surfaceSourceEntries, + surfaceSourceRepos, +} from "./surfaces.mjs" + +const BOT_LOGIN = /\[bot\]$/i +const DAY_MS = 86_400_000 +const DEFAULT_HALF_LIFE_DAYS = 180 +// Any of GitHub's write-capable repository permission levels. +const WRITE_PERMISSIONS = ["admin", "write", "maintain"] + +function paths(prefixes) { + return prefixes.map((p) => `\`${p}\``).join(", ") +} + +/** + * Recency-weighted contributor leaderboard. + * + * `commits` is `[{ login, type, date }]`. Each commit contributes + * `0.5 ** (ageDays / halfLifeDays)` to its author's score, so a commit one + * half-life old counts half as much as one today. Bots (author `type === "Bot"` + * or a login ending in `[bot]`) are skipped. Sorted by score desc, then login. + */ +export function rankContributors(commits, now, { halfLifeDays = DEFAULT_HALF_LIFE_DAYS } = {}) { + const nowMs = now instanceof Date ? now.getTime() : typeof now === "number" ? now : Date.parse(now) + const half = halfLifeDays > 0 ? halfLifeDays : DEFAULT_HALF_LIFE_DAYS + const totals = new Map() + + for (const commit of Array.isArray(commits) ? commits : []) { + const login = commit?.login + if (!login) continue + if (commit?.type === "Bot") continue + if (BOT_LOGIN.test(login)) continue + const at = Date.parse(commit?.date) + if (!Number.isFinite(at) || !Number.isFinite(nowMs)) continue + const ageDays = Math.max(0, (nowMs - at) / DAY_MS) + const entry = totals.get(login) ?? { login, score: 0, count: 0 } + entry.score += 0.5 ** (ageDays / half) + entry.count += 1 + totals.set(login, entry) + } + + return [...totals.values()].sort((a, b) => b.score - a.score || a.login.localeCompare(b.login)) +} + +/** + * The two reviewers for a surface. + * + * `other` has no source paths, so it returns the fixed pair configured in the + * map without touching the API. Every other surface is ranked from the git + * history of its source entries; an entry may name another repository (the + * cloud repo), in which case its history is read with the `cloudToken`. The + * commits each candidate authored are tagged with the repository they came + * from so the permission check asks that same repository. + * + * Candidates are walked in rank order and the first two with + * admin/write/maintain permission win. On a missing token, a missing repo, or + * any API failure a local surface returns no reviewers and a `note` naming the + * reason. A surface with a cloud entry instead falls back to the fixed `other` + * pair (`fallback: true`) and never throws, so its PR is still created. + */ +export async function computeSurfaceReviewers( + surface, + { api, repo, now, map, cloudToken = process.env.CLOUD_REPO_TOKEN } = {}, +) { + const m = map ?? loadSurfaceMap() + const otherName = m?.other?.name ?? OTHER + + if (surface === otherName || surface === OTHER) { + const reviewers = surfaceReviewers(otherName, m) + return { + reviewers: [...reviewers], + note: `\`${otherName}\` has no source paths to rank, so it keeps the fixed reviewers ${reviewers + .map((r) => `@${r}`) + .join(" and ")} from surfaces.json.`, + sourcePrefixes: [], + } + } + + const entries = surfaceSourceEntries(surface, m) + const prefixes = entries.map((e) => e.prefix) + if (entries.length === 0) { + return { + reviewers: [], + note: `no source prefixes are configured for surface \`${surface}\` in ${SURFACE_MAP_PATH}.`, + sourcePrefixes: [], + } + } + if (!repo) { + return { + reviewers: [], + note: `GITHUB_REPOSITORY is missing, so reviewers for \`${surface}\` could not be ranked (paths: ${paths(prefixes)}).`, + sourcePrefixes: prefixes, + } + } + if (typeof api !== "function") { + return { + reviewers: [], + note: `no GitHub API client was supplied, so reviewers for \`${surface}\` could not be ranked (paths: ${paths(prefixes)}).`, + sourcePrefixes: prefixes, + } + } + + // A cloud entry names a repository this job does not own; its history is only + // reachable with a token that grants `contents: read`. + const cloudRepos = surfaceSourceRepos(surface, m).filter((r) => r !== repo) + const fallback = surfaceReviewers(otherName, m) + const listRepos = (repos) => repos.map((r) => `\`${r}\``).join(", ") + + try { + if (cloudRepos.length > 0 && !cloudToken) { + const err = new Error( + `a token with contents: read on ${cloudRepos.join(", ")} is required (repository secret CROSS_REPO_ACCESS_TOKEN, exposed as CLOUD_REPO_TOKEN)`, + ) + err.code = "CLOUD_TOKEN_REQUIRED" + throw err + } + + const commits = [] + // `rankContributors` is pure and returns only `{ login, score, count }`, so + // remember where each author's commits came from for the permission check. + const origin = new Map() + for (const entry of entries) { + const remote = Boolean(entry.repo && entry.repo !== repo) + const target = entry.repo ?? repo + const auth = remote ? cloudToken : undefined + const batch = await api( + `/repos/${target}/commits?path=${encodeURIComponent(entry.prefix)}&per_page=100`, + auth === undefined ? {} : { auth }, + ) + for (const commit of Array.isArray(batch) ? batch : []) { + const login = commit?.author?.login + const date = commit?.commit?.author?.date + commits.push({ login, type: commit?.author?.type, date, repo: target, auth }) + if (!login) continue + const seen = origin.get(login) + if (!seen || Date.parse(date) > Date.parse(seen.date)) origin.set(login, { repo: target, auth, date }) + } + } + + const ranked = rankContributors(commits, now ?? Date.now()) + const reviewers = [] + for (const candidate of ranked) { + if (reviewers.length >= 2) break + const meta = origin.get(candidate.login) ?? {} + let level + try { + const perm = await api( + `/repos/${meta.repo ?? repo}/collaborators/${candidate.login}/permission`, + meta.auth === undefined ? {} : { auth: meta.auth }, + ) + level = perm?.permission ?? perm?.role_name + } catch (err) { + // 404 = not a collaborator; skip and try the next candidate. + if (err?.status === 404) continue + throw err + } + if (WRITE_PERMISSIONS.includes(level)) reviewers.push(candidate.login) + } + + const from = cloudRepos.length > 0 ? `${listRepos(cloudRepos)} git history` : "git history" + return { + reviewers, + note: `Reviewers for \`${surface}\` are ranked from ${from} over ${paths(prefixes)} (a commit ${DEFAULT_HALF_LIFE_DAYS} days old counts half as much, half-life ${DEFAULT_HALF_LIFE_DAYS} days). Bots (author type "Bot" or a login matching /\\[bot\\]$/i) and people without admin, write, or maintain permission are excluded.`, + sourcePrefixes: prefixes, + } + } catch (err) { + if (cloudRepos.length === 0) { + return { + reviewers: [], + note: `could not rank reviewers for \`${surface}\` over ${paths(prefixes)}: ${err?.message ?? err}`, + sourcePrefixes: prefixes, + } + } + return { + reviewers: [...fallback], + fallback: true, + note: `could not rank reviewers for \`${surface}\` from ${listRepos(cloudRepos)} over ${paths(prefixes)}: ${err?.message ?? err}. Fell back to the fixed \`${otherName}\` reviewers ${fallback + .map((r) => `@${r}`) + .join( + " and ", + )}; set repository secret CROSS_REPO_ACCESS_TOKEN (exposed as CLOUD_REPO_TOKEN) with contents: read on ${cloudRepos.join(", ")}.`, + sourcePrefixes: prefixes, + } + } +} diff --git a/.github/docs-sync/scope.test.mjs b/.github/docs-sync/scope.test.mjs new file mode 100644 index 0000000000..a38c97dffd --- /dev/null +++ b/.github/docs-sync/scope.test.mjs @@ -0,0 +1,50 @@ +// kilocode_change - new file + +/** + * Scope guard for the docs-sync change. + * + * The `bun install` run behind this change pruned + * `patches/ghostty-web@0.3.0.patch` and let bun rewrite the lockfile's + * `trustedDependencies` and `patchedDependencies` lists in its own order. Both + * are the package manager's output and are left exactly as `bun install` wrote + * them — restoring either by hand would only be pruned and re-sorted again. + * + * This guard records why the patch is genuinely orphaned, and fails if the + * docs-sync surface files go missing. + * + * Run: node .github/docs-sync/scope.test.mjs + */ + +import assert from "node:assert/strict" +import fs from "node:fs" +import path from "node:path" +import test from "node:test" +import { fileURLToPath } from "node:url" + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..") +const read = (file) => fs.readFileSync(path.join(ROOT, file), "utf8") + +test("the ghostty-web patch is orphaned, so bun install prunes it", () => { + const root = JSON.parse(read("package.json")) + const patched = Object.keys(root.patchedDependencies ?? {}) + assert.ok( + !patched.some((key) => key.startsWith("ghostty-web")), + `nothing patches ghostty-web, so patches/ghostty-web@0.3.0.patch is orphaned and bun install prunes it; patchedDependencies: ${patched.join(", ")}`, + ) + // The only consumer is on 0.4.0, so nothing looks for the 0.3.0 patch. + const consumer = JSON.parse(read("packages/kilo-console/package.json")) + const version = consumer.dependencies?.["ghostty-web"] ?? consumer.devDependencies?.["ghostty-web"] ?? "" + assert.match(version, /^0\.4\./, "the only ghostty-web consumer must be on 0.4.x") +}) + +test("the docs-sync surface files are still present", () => { + for (const file of ["surfaces.json", "surfaces.mjs", "reviewers.mjs", "prepare-branch.mjs", "upsert-pr.mjs"]) { + assert.ok(fs.existsSync(path.join(ROOT, ".github/docs-sync", file)), `.github/docs-sync/${file} must exist`) + } + const map = JSON.parse(read(".github/docs-sync/surfaces.json")) + const names = (map.surfaces ?? []).map((s) => s.name) + for (const name of ["cli", "vscode", "jetbrains", "gateway", "web"]) { + assert.ok(names.includes(name), `surface map must still enumerate ${name}`) + } + assert.equal(map.other?.name, "other") +}) diff --git a/.github/docs-sync/selftest.mjs b/.github/docs-sync/selftest.mjs index 8341b0ef9a..c2193aac1d 100644 --- a/.github/docs-sync/selftest.mjs +++ b/.github/docs-sync/selftest.mjs @@ -15,7 +15,8 @@ import { fileURLToPath } from "node:url" import { sleepSync } from "./lib.mjs" import { mergeOrFallback, DEFAULT_BRANCH } from "./prepare-branch.mjs" -import { applyCap } from "./watermark.mjs" +import { isSurfaceBranch, surfaceNameFromBranch } from "./surfaces.mjs" +import { applyCap, pickWatermark } from "./watermark.mjs" import { computeUncovered, computeProcessedThrough, @@ -25,8 +26,10 @@ import { LEARNINGS_FILE, nonContentFiles, resolveLearnedThrough, + patchProcessedThrough, renderBody, extractSectionRows, + surfaceBranch, } from "./upsert-pr.mjs" import { revertTitleKind, @@ -52,6 +55,8 @@ const EDIT_SCRIPT = path.join(HERE, "edit.mjs") const TRIAGE_SCRIPT = path.join(HERE, "triage.mjs") const COLLECT_SCRIPT = path.join(HERE, "collect.mjs") const LEARN_SCRIPT = path.join(HERE, "learn.mjs") +const UPSERT_SCRIPT = path.join(HERE, "upsert-pr.mjs") +const PREP_SCRIPT = path.join(HERE, "prepare-branch.mjs") const temps = [] @@ -1873,10 +1878,10 @@ function case10_learnings() { } // Prepare a git repo for learn.mjs tests: set origin refs, create docs-sync-out. - function setupLearnRepo(dir) { + function setupLearnRepo(dir, branch = "docs/auto-sync") { fs.mkdirSync(path.join(dir, "docs-sync-out"), { recursive: true }) gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"]) - gitIn(dir, ["update-ref", "refs/remotes/origin/docs/auto-sync", "docs/auto-sync"]) + gitIn(dir, ["update-ref", `refs/remotes/origin/${branch}`, branch]) return dir } @@ -2956,19 +2961,19 @@ Just prose, not a rule line. ) assert.ok(upsertSrc.includes("prBody"), "resolveLearnedThrough must receive prBody") - // (b) prBody is at function scope (let prBody before the if block) + // (b) prBody is function-scoped and read before the body is rendered const prBodyIdx = upsertSrc.indexOf('let prBody = ""') assert.ok(prBodyIdx >= 0, 'prBody must be declared at function scope with let prBody = ""') - const ifIdx = upsertSrc.indexOf('if (mode === "update"') - assert.ok(prBodyIdx < ifIdx, 'let prBody must appear before if (mode === "update"...)') - // (c) renderBody({ argument object contains learnedThrough + // (c) the renderBody({ argument object contains learnedThrough + surfaceBlock const renderBodyIdx = upsertSrc.indexOf("const body = renderBody({") assert.ok(renderBodyIdx >= 0, "renderBody call must exist") + assert.ok(prBodyIdx < renderBodyIdx, "let prBody must appear before the renderBody call") const afterRenderBody = upsertSrc.slice(renderBodyIdx) const renderBodyArgsEnd = afterRenderBody.indexOf("})") const renderBodyArgs = afterRenderBody.slice(0, renderBodyArgsEnd) - assert.ok(renderBodyArgs.includes("learnedThrough"), "renderBody call in main() must pass learnedThrough") + assert.ok(renderBodyArgs.includes("learnedThrough"), "renderBody call must pass learnedThrough") + assert.ok(renderBodyArgs.includes("surfaceBlock"), "renderBody call must pass the surface block") } // 10q — dry run makes no live write @@ -3224,7 +3229,7 @@ Just prose, not a rule line. fs.writeFileSync(path.join(dir, "base.txt"), "base\n") gitIn(dir, ["add", "base.txt"]) gitIn(dir, ["commit", "-m", "base"]) - gitIn(dir, ["checkout", "-b", "docs/auto-sync"]) + gitIn(dir, ["checkout", "-b", "docs/auto-sync/cli"]) // github-actions[bot] authored the only branch commit, so there is no candidate // correction and no model call. The run goes straight to the direct marker PATCH. @@ -3234,7 +3239,7 @@ Just prose, not a rule line. gitIn(dir, ["add", "packages/kilo-docs/LEARNINGS.md"]) gitIn(dir, ["commit", "-m", "seed learnings", "--author", `github-actions[bot] <${githubBotEmail}>`]) gitIn(dir, ["remote", "add", "origin", dir]) // learn.mjs fetches origin itself - const cwd = setupLearnRepo(dir) + const cwd = setupLearnRepo(dir, "docs/auto-sync/cli") const tip = gitIn(dir, ["rev-parse", "HEAD"]) // Stub GitHub API. The second read of the pull request returns the maintainer edit. @@ -3263,7 +3268,7 @@ const server = http.createServer((req, res) => { return json(res, { number: 1, body, - head: { ref: "docs/auto-sync" }, + head: { ref: "docs/auto-sync/cli" }, user: { login: "github-actions[bot]" }, }) } @@ -3429,26 +3434,1191 @@ server.listen(0, "127.0.0.1", () => fs.writeFileSync(process.env.PORT_FILE, Stri } } -// Case 11 — the created rolling PR gets an assignee and a review request +// Case 11 — created per-surface PRs get an assignee and a review request function case11_prOwner() { - console.log("case 11 — created PR assignee and reviewer") - const src = fs.readFileSync(path.join(HERE, "upsert-pr.mjs"), "utf8") + console.log("case 11 — per-surface assignee and reviewer calls") + const src = fs.readFileSync(UPSERT_SCRIPT, "utf8") + + // The single rolling owner is gone; reviewers come from the surface map. + assert.ok(!src.includes("DOCS_OWNER"), "the single DOCS_OWNER constant must be removed") + assert.ok(src.includes("computeSurfaceReviewers"), "upsert must compute per-surface reviewers") + assert.ok(src.includes("groupBySurface"), "upsert must group docs changes by surface") + assert.equal(surfaceBranch("cli"), "docs/auto-sync/cli") + assert.equal(surfaceBranch("other"), "docs/auto-sync/other") - assert.ok(/const DOCS_OWNER = "\S+"/.test(src), "DOCS_OWNER must be a module constant") + // Created PRs still POST assignees and requested_reviewers, best-effort. assert.ok(src.includes("/assignees`, {"), "created PR must POST assignees") assert.ok(src.includes("/requested_reviewers`, {"), "created PR must POST requested_reviewers") + const assignIdx = src.indexOf("/assignees`, {") + const reviewIdx = src.indexOf("/requested_reviewers`, {") + const tryIdx = src.lastIndexOf("try {", assignIdx) + const catchIdx = src.indexOf("} catch", assignIdx) + assert.ok(tryIdx >= 0 && catchIdx > reviewIdx, "both POSTs must sit in one try/catch") + + // prepare-branch must ignore legacy rolling PRs entirely: it recognizes only + // per-surface branches, never comments on a legacy PR, and never closes one. + const prepSrc = fs.readFileSync(PREP_SCRIPT, "utf8") + assert.ok(prepSrc.includes("isSurfaceBranch"), "prepare-branch must recognize per-surface branches") + assert.ok(!prepSrc.includes('state: "closed"'), "prepare-branch must not close a legacy PR") + assert.ok(!prepSrc.includes("/issues/"), "prepare-branch must not comment on a legacy PR") +} + +// --------------------------------------------------------------------------- +// Case 12/13 — per-surface segmentation against a stub API +// --------------------------------------------------------------------------- +const SURFACE_NOW = "2026-09-01T00:00:00.000Z" +const SURFACE_PREFIX = "docs/auto-sync/" +const GITHUB_BOT_EMAIL = "41898282+github-actions[bot]@users.noreply.github.com" +const SURFACE_PAGE_FILES = { + cli: "packages/kilo-docs/pages/getting-started/new.md", + vscode: "packages/kilo-docs/pages/code-with-ai/platforms/vscode/new.md", + gateway: "packages/kilo-docs/pages/gateway/new.md", + other: "packages/kilo-docs/pages/community/new.md", +} + +// Stub GitHub API. Routes are narrow: search, one pull read, commits by path, +// collaborator permission, and the write endpoints we capture. No network. +const SURFACE_STUB_SERVER = ` +const fs = require("node:fs") +const http = require("node:http") +const config = JSON.parse(fs.readFileSync(process.env.STUB_CONFIG_FILE, "utf8")) +let nextPr = 100 +function log(entry) { fs.appendFileSync(process.env.STUB_LOG_FILE, JSON.stringify(entry) + "\\n") } +function send(res, status, data) { + res.writeHead(status, { "content-type": "application/json" }) + res.end(JSON.stringify(data)) +} +const server = http.createServer((req, res) => { + let raw = "" + req.on("data", (c) => (raw += c)) + req.on("end", () => { + let body = null + if (raw) { try { body = JSON.parse(raw) } catch (e) { body = raw } } + const path = String(req.url).split("?")[0] + log({ method: req.method, url: req.url, body, auth: req.headers.authorization }) + if (req.method === "GET" && path === "/search/issues") return send(res, 200, { items: config.openPrs || [] }) + const pullMatch = path.match(/^\\/repos\\/[^/]+\\/[^/]+\\/pulls\\/(\\d+)$/) + if (req.method === "GET" && pullMatch) { + const n = Number(pullMatch[1]) + const pr = (config.openPrs || []).find((p) => p.number === n) || {} + return send(res, 200, { number: n, head: { ref: pr.head || "" }, body: pr.body || "", html_url: "https://example.test/pull/" + n }) + } + if (req.method === "GET" && /\\/pulls\\/\\d+\\/comments$/.test(path)) return send(res, 200, []) + if (req.method === "GET" && path.endsWith("/commits")) { + const query = String(req.url).split("?")[1] || "" + const m = query.match(/path=([^&]*)/) + const prefix = m ? decodeURIComponent(m[1]) : "" + // A repo-qualified key wins; a bare prefix keeps the existing cases working. + const repoMatch = path.match(/^\\/repos\\/(.+)\\/commits$/) + const key = repoMatch ? repoMatch[1] + "|" + prefix : prefix + if ((config.failCommits || []).includes(key) || (config.failCommits || []).includes(prefix)) return send(res, 403, { message: "Resource not accessible by integration" }) + return send(res, 200, (config.commits || {})[key] ?? (config.commits || {})[prefix] ?? []) + } + if (req.method === "GET" && path.indexOf("/collaborators/") >= 0 && path.endsWith("/permission")) { + const login = decodeURIComponent(path.split("/collaborators/")[1].replace("/permission", "")) + return send(res, 200, { permission: (config.permissions || {})[login] || "write" }) + } + if (req.method === "POST" && /\\/pulls$/.test(path)) { + const head = body && body.head + if (config.failHead && head === config.failHead) return send(res, 400, { message: "stub rejected " + head }) + const number = nextPr++ + const url = "https://example.test/pull/" + number + log({ kind: "create", head: head, number: number, url: url, body: body }) + return send(res, 201, { number: number, html_url: url, head: { ref: head } }) + } + if (req.method === "PATCH" && pullMatch) { + const n = Number(pullMatch[1]) + if ((config.failPatch || []).includes(n)) return send(res, 422, { message: "stub rejected patch " + n }) + return send(res, 200, { number: n, html_url: "https://example.test/pull/" + n }) + } + if (req.method === "POST" && path.endsWith("/labels")) return send(res, 201, {}) + if (req.method === "POST" && path.endsWith("/assignees")) return send(res, 200, {}) + if (req.method === "POST" && path.endsWith("/requested_reviewers")) return send(res, 200, {}) + send(res, 404, { message: "unhandled " + req.method + " " + req.url }) + }) +}) +server.listen(0, "127.0.0.1", function () { fs.writeFileSync(process.env.STUB_PORT_FILE, String(server.address().port)) }) +` - // Both calls belong to the create arm, after the PR exists. - const createIdx = src.indexOf("const pr = await api(`/repos/${repo()}/pulls`") - assert.ok(createIdx >= 0, "create-PR call must exist") - assert.ok(src.indexOf("/assignees`, {") > createIdx, "assignees POST must follow PR creation") - assert.ok(src.indexOf("/requested_reviewers`, {") > createIdx, "reviewer POST must follow PR creation") +function stubCommit(login, daysAgo) { + const at = Date.parse(SURFACE_NOW) - daysAgo * 86_400_000 + return { author: { login, type: "User" }, commit: { author: { date: new Date(at).toISOString() } } } +} + +function startSurfaceStub(config) { + const dir = mktemp("docs-sync-stub-") + const portFile = path.join(dir, "port") + const logFile = path.join(dir, "requests.jsonl") + const configFile = path.join(dir, "config.json") + fs.writeFileSync(configFile, JSON.stringify(config, null, 2)) + const script = path.join(dir, "server.cjs") + fs.writeFileSync(script, SURFACE_STUB_SERVER) + const child = spawn(process.execPath, [script], { + stdio: "ignore", + env: { ...process.env, STUB_PORT_FILE: portFile, STUB_LOG_FILE: logFile, STUB_CONFIG_FILE: configFile }, + }) + let port = "" + for (let i = 0; i < 200 && !port; i++) { + if (fs.existsSync(portFile)) port = fs.readFileSync(portFile, "utf8").trim() + else sleepSync(25) + } + if (!port) { + child.kill() + throw new Error("stub API did not report a port") + } + return { dir, logFile, port, child } +} + +function readStubLog(logFile) { + if (!fs.existsSync(logFile)) return [] + return fs + .readFileSync(logFile, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((l) => JSON.parse(l)) +} + +// Temp repo with a bare origin, main carrying base docs, and the integration +// branch checked out with the working-tree docs changes that span two surfaces +// plus one unmapped file. +function setupSurfaceRepo() { + const root = mktemp("docs-sync-surface-") + const originDir = path.join(root, "origin.git") + gitIn(root, ["init", "--bare", "origin.git"]) + const repoDir = path.join(root, "repo") + fs.mkdirSync(repoDir) + initRepoWithIdentity(repoDir) + for (const section of ["getting-started", "gateway", "community"]) { + const p = path.join(repoDir, "packages", "kilo-docs", "pages", section, "base.md") + fs.mkdirSync(path.dirname(p), { recursive: true }) + fs.writeFileSync(p, "# base\n") + } + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", "base docs"]) + gitIn(repoDir, ["remote", "add", "origin", originDir]) + gitIn(repoDir, ["push", "-q", "origin", "main"]) + // The integration branch is local; surface branches are pushed instead. + gitIn(repoDir, ["checkout", "-q", "-b", "docs/auto-sync"]) + for (const file of Object.values(SURFACE_PAGE_FILES)) { + const p = path.join(repoDir, file) + fs.mkdirSync(path.dirname(p), { recursive: true }) + fs.writeFileSync(p, "# new\n") + } + return { root, repoDir } +} + +function runUpsert(repoDir, root, port, extraEnv = {}) { + return runNodeScript(UPSERT_SCRIPT, { + cwd: repoDir, + env: { + GITHUB_REPOSITORY: "acme/repo", + GH_TOKEN: "stub-token", + DOCS_SYNC_API_BASE: `http://127.0.0.1:${port}`, + PROCESSED_THROUGH: SURFACE_NOW, + SINCE: "2026-08-01T00:00:00.000Z", + VERIFIED: "true", + BRANCH: "docs/auto-sync", + PREP_MODE: "update", + GITHUB_OUTPUT: path.join(root, "gh-output"), + GITHUB_STEP_SUMMARY: path.join(root, "gh-summary"), + ...extraEnv, + }, + }) +} - // A failure here must not fail the run — the PR is already open. - const ownerIdx = src.indexOf("/assignees`, {") - const tryIdx = src.lastIndexOf("try {", ownerIdx) - const catchIdx = src.indexOf("} catch", ownerIdx) - assert.ok(tryIdx >= 0 && catchIdx > src.indexOf("/requested_reviewers`, {"), "both POSTs must sit in one try/catch") +function case12_surfaceSegmentation() { + console.log("case 12 — per-surface segmentation") + const stub = startSurfaceStub({ + openPrs: [], + commits: { + "packages/opencode/": [stubCommit("alice", 1), stubCommit("bob", 5)], + "packages/kilo-vscode/": [stubCommit("erin", 1), stubCommit("frank", 3)], + "packages/kilo-gateway/": [stubCommit("carol", 2), stubCommit("dave", 4)], + }, + permissions: { alice: "write", bob: "write", erin: "write", frank: "write", carol: "write", dave: "write" }, + }) + try { + const { root, repoDir } = setupSurfaceRepo() + const result = runUpsert(repoDir, root, stub.port) + assert.equal(result.status, 0, `upsert must exit 0: ${result.output}`) + + const entries = readStubLog(stub.logFile) + const creates = entries.filter((e) => e.kind === "create") + assert.equal( + creates.length, + 4, + `exactly four PRs must be created; got ${JSON.stringify(creates)}\n${result.output}`, + ) + const byHead = new Map(creates.map((c) => [c.head, c])) + assert.deepEqual([...byHead.keys()].sort(), [ + `${SURFACE_PREFIX}cli`, + `${SURFACE_PREFIX}gateway`, + `${SURFACE_PREFIX}other`, + `${SURFACE_PREFIX}vscode`, + ]) + + const assignees = entries.filter((e) => e.method === "POST" && e.url.endsWith("/assignees")) + const reviews = entries.filter((e) => e.method === "POST" && e.url.endsWith("/requested_reviewers")) + const pairFor = (name) => { + const create = byHead.get(`${SURFACE_PREFIX}${name}`) + const a = assignees.find((e) => e.url.includes(`/issues/${create.number}/`)) + const r = reviews.find((e) => e.url.includes(`/pulls/${create.number}/`)) + assert.ok(a, `assignee POST for ${name}`) + assert.ok(r, `reviewer POST for ${name}`) + return { assignees: a.body.assignees, reviewers: r.body.reviewers } + } + assert.deepEqual(pairFor("cli"), { assignees: ["alice", "bob"], reviewers: ["alice", "bob"] }) + assert.deepEqual(pairFor("vscode"), { assignees: ["erin", "frank"], reviewers: ["erin", "frank"] }) + assert.deepEqual(pairFor("gateway"), { assignees: ["carol", "dave"], reviewers: ["carol", "dave"] }) + assert.deepEqual(pairFor("other"), { + assignees: ["lambertjosh", "intentionally-left-nil"], + reviewers: ["lambertjosh", "intentionally-left-nil"], + }) + + for (const create of creates) { + const body = create.body.body + assert.match(body, /Derived from the repository layout/, `derivation in ${create.head}`) + assert.match(body, /`packages\/kilo-docs\/pages\/community\/`/, `other paths in ${create.head}`) + assert.match(body, /\.github\/docs-sync\/surfaces\.json/, `map file in ${create.head}`) + } + assert.match(byHead.get(`${SURFACE_PREFIX}cli`).body.body, /half-life 180 days/) + assert.match(byHead.get(`${SURFACE_PREFIX}gateway`).body.body, /half-life 180 days/) + assert.match(byHead.get(`${SURFACE_PREFIX}other`).body.body, /fixed reviewers/i) + + // Each surface branch carries exactly its own changed file. + const branchFiles = new Map() + for (const name of Object.keys(SURFACE_PAGE_FILES)) { + const head = `${SURFACE_PREFIX}${name}` + const diff = gitIn(repoDir, ["diff", "--name-only", "origin/main", `origin/${head}`]) + branchFiles.set(name, diff.split("\n").filter(Boolean)) + } + for (const [name, file] of Object.entries(SURFACE_PAGE_FILES)) { + assert.deepEqual(branchFiles.get(name), [file], `branch ${name} must carry only its own file`) + } + const flat = [...branchFiles.values()].flat() + assert.equal(flat.length, 4, "four changed files total") + assert.equal(new Set(flat).size, 4, "no changed file may appear in two PRs") + } finally { + stub.child.kill() + } +} + +function case13_surfaceFailureIsolated() { + console.log("case 13 — one surface's failure is isolated") + const stub = startSurfaceStub({ + openPrs: [], + failHead: `${SURFACE_PREFIX}cli`, + commits: { "packages/kilo-gateway/": [stubCommit("carol", 2), stubCommit("dave", 4)] }, + permissions: { carol: "write", dave: "write" }, + }) + try { + const { root, repoDir } = setupSurfaceRepo() + const result = runUpsert(repoDir, root, stub.port) + assert.equal(result.status, 0, `run must exit 0 when one surface fails: ${result.output}`) + assert.match(result.output, /surface cli failed/, "the failure must be warned, not thrown") + + const creates = readStubLog(stub.logFile).filter((e) => e.kind === "create") + assert.equal(creates.length, 3, "the other three surfaces must still create PRs") + assert.deepEqual([...new Set(creates.map((c) => c.head))].sort(), [ + `${SURFACE_PREFIX}gateway`, + `${SURFACE_PREFIX}other`, + `${SURFACE_PREFIX}vscode`, + ]) + } finally { + stub.child.kill() + } +} + +function case14_prepareBranchSurfaces() { + console.log("case 14 — prepare-branch merges surface branches without a ref collision") + const root = mktemp("docs-sync-prep-") + const originDir = path.join(root, "origin.git") + gitIn(root, ["init", "--bare", "origin.git"]) + const repoDir = path.join(root, "repo") + fs.mkdirSync(repoDir) + initRepoWithIdentity(repoDir) + + const write = (rel, text) => { + const p = path.join(repoDir, rel) + fs.mkdirSync(path.dirname(p), { recursive: true }) + fs.writeFileSync(p, text) + } + write("packages/kilo-docs/pages/getting-started/base.md", "# base\n") + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", "base"]) + gitIn(repoDir, ["remote", "add", "origin", originDir]) + gitIn(repoDir, ["push", "-q", "origin", "main"]) + + // Durability integration branch. + gitIn(repoDir, ["checkout", "-q", "-b", "docs/auto-sync-integration"]) + write("packages/kilo-docs/pages/getting-started/integ.md", "# integ\n") + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", "integ"]) + gitIn(repoDir, ["push", "-q", "origin", "docs/auto-sync-integration"]) + + // Surface branch carrying a human commit. + gitIn(repoDir, ["checkout", "-q", "main"]) + gitIn(repoDir, ["checkout", "-q", "-b", "docs/auto-sync/cli"]) + write("packages/kilo-docs/pages/getting-started/human.md", "# human\n") + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", "human"]) + gitIn(repoDir, ["push", "-q", "origin", "docs/auto-sync/cli"]) + // actions/checkout leaves other branches as remote-tracking refs; drop the + // local surface branch so only the integration branch name is a local head. + gitIn(repoDir, ["checkout", "-q", "main"]) + gitIn(repoDir, ["branch", "-D", "docs/auto-sync/cli"]) + + const stub = startSurfaceStub({ openPrs: [{ number: 7, head: "docs/auto-sync/cli", body: "" }] }) + try { + const outputFile = path.join(root, "gh-output") + const result = runNodeScript(PREP_SCRIPT, { + cwd: repoDir, + env: { + GITHUB_REPOSITORY: "acme/repo", + GH_TOKEN: "stub-token", + DOCS_SYNC_API_BASE: `http://127.0.0.1:${stub.port}`, + GITHUB_OUTPUT: outputFile, + }, + }) + assert.equal(result.status, 0, `prepare-branch must exit 0: ${result.output}`) + const out = fs.readFileSync(outputFile, "utf8") + assert.match(out, /branch=docs\/auto-sync\n/, "integration branch output") + assert.match(out, /mode=update\n/, "mode output") + assert.ok( + fs.existsSync(path.join(repoDir, "packages/kilo-docs/pages/getting-started/human.md")), + "the surface branch's human commit must be merged into the integration tree", + ) + assert.ok( + fs.existsSync(path.join(repoDir, "packages/kilo-docs/pages/getting-started/integ.md")), + "the durability content must be present", + ) + } finally { + stub.child.kill() + } +} + +function case15_surfaceUpdate() { + console.log("case 15 — updating an open surface PR preserves human commits") + const stub = startSurfaceStub({ + openPrs: [ + { + number: 42, + head: `${SURFACE_PREFIX}cli`, + body: "old body\n\n| old change | [acme#1](https://example.test/1) |\n\n", + }, + ], + commits: { + "packages/opencode/": [stubCommit("alice", 1), stubCommit("bob", 5)], + "packages/kilo-gateway/": [stubCommit("carol", 2), stubCommit("dave", 4)], + }, + permissions: { alice: "write", bob: "write", carol: "write", dave: "write" }, + }) + try { + const { root, repoDir } = setupSurfaceRepo() + + // A human commit on the open cli PR branch. + gitIn(repoDir, ["checkout", "-q", "-b", "human-tmp", "origin/main"]) + const human = path.join(repoDir, "packages/kilo-docs/pages/getting-started/human.md") + fs.mkdirSync(path.dirname(human), { recursive: true }) + fs.writeFileSync(human, "# human\n") + gitIn(repoDir, ["add", "packages/kilo-docs/pages/getting-started/human.md"]) + gitIn(repoDir, ["commit", "-m", "human edit"]) + gitIn(repoDir, ["push", "-q", "origin", "HEAD:refs/heads/docs/auto-sync/cli"]) + gitIn(repoDir, ["checkout", "-q", "docs/auto-sync"]) + gitIn(repoDir, ["branch", "-D", "human-tmp"]) + + const result = runUpsert(repoDir, root, stub.port) + assert.equal(result.status, 0, `upsert must exit 0: ${result.output}`) + + const entries = readStubLog(stub.logFile) + const creates = entries.filter((e) => e.kind === "create") + assert.equal(creates.length, 3, "only the three other surfaces are created") + assert.ok(!creates.some((c) => c.head === `${SURFACE_PREFIX}cli`), "cli must be updated, not re-created") + const patched = entries.find((e) => e.method === "PATCH" && e.url.includes("/pulls/42")) + assert.ok(patched, "the open cli PR must be PATCHed") + assert.match(patched.body.body, /old change/, "existing rows must be carried forward") + + // The updated cli branch keeps the human commit and adds the new cli file. + const diff = gitIn(repoDir, ["diff", "--name-only", "origin/main", `origin/${SURFACE_PREFIX}cli`]) + assert.deepEqual(diff.split("\n").filter(Boolean).sort(), [ + "packages/kilo-docs/pages/getting-started/human.md", + "packages/kilo-docs/pages/getting-started/new.md", + ]) + } finally { + stub.child.kill() + } +} + +function case16_surfaceDeletion() { + console.log("case 16 — a deleted doc file reaches its surface PR") + const stub = startSurfaceStub({ + openPrs: [], + commits: { + "packages/opencode/": [stubCommit("alice", 1)], + "packages/kilo-gateway/": [stubCommit("carol", 2), stubCommit("dave", 4)], + }, + permissions: { alice: "write", carol: "write", dave: "write" }, + }) + try { + const { root, repoDir } = setupSurfaceRepo() + // The integration tree removes a page that still exists on origin/main. + const gone = path.join(repoDir, "packages/kilo-docs/pages/gateway/base.md") + assert.ok(fs.existsSync(gone), "fixture must start with the gateway base page") + fs.rmSync(gone) + gitIn(repoDir, ["rm", "-q", "packages/kilo-docs/pages/gateway/base.md"]) + gitIn(repoDir, ["commit", "-m", "remove gateway base page"]) + + const result = runUpsert(repoDir, root, stub.port) + assert.equal(result.status, 0, `upsert must exit 0 with a deletion: ${result.output}`) + assert.ok(!/surface gateway failed/.test(result.output), `a deletion must not fail its surface: ${result.output}`) + + const creates = readStubLog(stub.logFile).filter((e) => e.kind === "create") + const gateway = creates.find((c) => c.head === `${SURFACE_PREFIX}gateway`) + assert.ok(gateway, "the gateway surface PR must still be created") + + const status = gitIn(repoDir, ["diff", "--name-status", "origin/main", `origin/${SURFACE_PREFIX}gateway`]) + const lines = status.split("\n").filter(Boolean) + assert.ok( + lines.includes("D\tpackages/kilo-docs/pages/gateway/base.md"), + `the gateway branch must carry the deletion; got:\n${status}`, + ) + assert.ok( + lines.includes("A\tpackages/kilo-docs/pages/gateway/new.md"), + `the gateway branch must still add its new page; got:\n${status}`, + ) + } finally { + stub.child.kill() + } +} + +// --------------------------------------------------------------------------- +// Case 17 — learnings read every open surface PR +// --------------------------------------------------------------------------- +function case17_learnAllSurfaces() { + console.log("case 17 — learnings cover every surface PR") + + const humanBotEmail = "240665456+kiloconnect[bot]@users.noreply.github.com" + + const dir = mktemp("docs-sync-learn-multi-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "base.txt"), "base\n") + gitIn(dir, ["add", "base.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + + const addDoc = (rel, text) => { + const p = path.join(dir, rel) + fs.mkdirSync(path.dirname(p), { recursive: true }) + fs.writeFileSync(p, text) + } + + gitIn(dir, ["checkout", "-q", "-b", "docs/auto-sync/cli"]) + addDoc("packages/kilo-docs/pages/cli.md", "# cli\n") + gitIn(dir, ["add", "packages/kilo-docs"]) + gitIn(dir, ["commit", "-m", "cli edit", "--author", `kiloconnect[bot] <${humanBotEmail}>`]) + const cliSha = gitIn(dir, ["rev-parse", "HEAD"]) + + gitIn(dir, ["checkout", "-q", "main"]) + gitIn(dir, ["checkout", "-q", "-b", "docs/auto-sync/vscode"]) + addDoc("packages/kilo-docs/pages/vscode.md", "# vscode\n") + gitIn(dir, ["add", "packages/kilo-docs"]) + gitIn(dir, ["commit", "-m", "vscode edit", "--author", `kiloconnect[bot] <${humanBotEmail}>`]) + const vscodeSha = gitIn(dir, ["rev-parse", "HEAD"]) + + // origin refs, as actions/checkout would leave them. + gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"]) + gitIn(dir, ["update-ref", "refs/remotes/origin/docs/auto-sync/cli", "docs/auto-sync/cli"]) + gitIn(dir, ["update-ref", "refs/remotes/origin/docs/auto-sync/vscode", "docs/auto-sync/vscode"]) + fs.mkdirSync(path.join(dir, "docs-sync-out"), { recursive: true }) + + const prs = () => [ + { number: 1, head: { ref: "docs/auto-sync/cli" }, body: "", user: { login: "github-actions[bot]" } }, + { number: 2, head: { ref: "docs/auto-sync/vscode" }, body: "", user: { login: "github-actions[bot]" } }, + ] + + // First run: both branches' corrections must reach the model input. + { + const fixturePath = path.join(dir, "fixture.json") + fs.writeFileSync(fixturePath, JSON.stringify({ prs: prs(), comments: [] }, null, 2)) + const callLog = path.join(dir, "kilo-calls.log") + const kiloDir = makeStubKiloDir({ mode: "extraction-delta", callLog }) + fs.writeFileSync(path.join(dir, "docs-sync-out", "extraction-delta.json"), JSON.stringify({ add: [], remove: [] })) + const result = runNodeScript(LEARN_SCRIPT, { + cwd: dir, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + }, + }) + assert.equal(result.status, 0, `multi-surface extraction must exit 0: ${result.output}`) + const input = JSON.parse(fs.readFileSync(path.join(dir, "docs-sync-out", "learnings-input.json"), "utf8")) + const sources = input.corrections.map((c) => c.source).sort() + assert.deepEqual( + sources, + [`commit:${cliSha.slice(0, 7)}`, `commit:${vscodeSha.slice(0, 7)}`].sort(), + `every surface branch's correction must be a candidate; got ${JSON.stringify(sources)}`, + ) + const patched = fs.readFileSync(`${fixturePath}.patched`, "utf8") + assert.ok( + patched.includes(cliSha) && patched.includes(vscodeSha), + `the marker must list every learned tip; got ${patched}`, + ) + } + + // Second run: the union watermark covers both branches, so nothing is re-learned. + { + const marker = fs.readFileSync(path.join(dir, "fixture.json.patched"), "utf8") + const fixturePath = path.join(dir, "fixture2.json") + fs.writeFileSync( + fixturePath, + JSON.stringify({ prs: prs().map((p) => ({ ...p, body: marker })), comments: [] }, null, 2), + ) + const callLog2 = path.join(dir, "kilo-calls2.log") + const kiloDir2 = makeStubKiloDir({ mode: "extraction-delta", callLog: callLog2 }) + const result = runNodeScript(LEARN_SCRIPT, { + cwd: dir, + kiloDir: kiloDir2, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + }, + }) + assert.equal(result.status, 0, `second multi-surface run must exit 0: ${result.output}`) + const calls = fs.existsSync(callLog2) ? fs.readFileSync(callLog2, "utf8").trim() : "" + assert.equal(calls, "", "a marker covering every tip must suppress the model call") + } + + // A PR whose branch is gone must not block learning from the others. + { + const fixturePath = path.join(dir, "fixture3.json") + fs.writeFileSync( + fixturePath, + JSON.stringify( + { + prs: [ + { number: 3, head: { ref: "docs/auto-sync/gone" }, body: "", user: { login: "github-actions[bot]" } }, + { number: 4, head: { ref: "docs/auto-sync/cli" }, body: "", user: { login: "github-actions[bot]" } }, + ], + comments: [], + }, + null, + 2, + ), + ) + const callLog3 = path.join(dir, "kilo-calls3.log") + const kiloDir3 = makeStubKiloDir({ mode: "extraction-delta", callLog: callLog3 }) + fs.writeFileSync(path.join(dir, "docs-sync-out", "extraction-delta.json"), JSON.stringify({ add: [], remove: [] })) + const result = runNodeScript(LEARN_SCRIPT, { + cwd: dir, + kiloDir: kiloDir3, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + }, + }) + assert.equal(result.status, 0, `an unreadable branch must not fail the run: ${result.output}`) + const input = JSON.parse(fs.readFileSync(path.join(dir, "docs-sync-out", "learnings-input.json"), "utf8")) + assert.ok( + input.corrections.some((c) => c.source === `commit:${cliSha.slice(0, 7)}`), + "the readable branch must still be learned", + ) + } +} + +// --------------------------------------------------------------------------- +// Case 18 — a lingering legacy ref is deleted without an open legacy PR +// --------------------------------------------------------------------------- +function case18_legacyRefDeletedWithoutPr() { + console.log("case 18 — a lingering legacy ref is deleted without an open legacy PR") + const root = mktemp("docs-sync-prep-legacy-") + const originDir = path.join(root, "origin.git") + gitIn(root, ["init", "--bare", "origin.git"]) + const repoDir = path.join(root, "repo") + fs.mkdirSync(repoDir) + initRepoWithIdentity(repoDir) + + const write = (rel, text) => { + const p = path.join(repoDir, rel) + fs.mkdirSync(path.dirname(p), { recursive: true }) + fs.writeFileSync(p, text) + } + write("packages/kilo-docs/pages/getting-started/base.md", "# base\n") + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", "base"]) + gitIn(repoDir, ["remote", "add", "origin", originDir]) + gitIn(repoDir, ["push", "-q", "origin", "main"]) + + gitIn(repoDir, ["checkout", "-q", "-b", "docs/auto-sync-integration"]) + write("packages/kilo-docs/pages/getting-started/integ.md", "# integ\n") + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", "integ"]) + gitIn(repoDir, ["push", "-q", "origin", "docs/auto-sync-integration"]) + + // A legacy docs/auto-sync branch lingers on origin with no open legacy PR. + // It must be pushed from a temp branch: origin (like a local repo) refuses to + // hold both `docs/auto-sync` and `docs/auto-sync/cli`. + gitIn(repoDir, ["checkout", "-q", "-b", "legacy-tmp", "main"]) + write("packages/kilo-docs/pages/getting-started/legacy.md", "# legacy\n") + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", "legacy"]) + const legacySha = gitIn(repoDir, ["rev-parse", "HEAD"]) + gitIn(repoDir, ["push", "-q", "origin", "HEAD:refs/heads/docs/auto-sync"]) + + // actions/checkout leaves the remote-tracking ref for every origin branch. + gitIn(repoDir, ["update-ref", "refs/remotes/origin/docs/auto-sync", legacySha]) + gitIn(repoDir, ["checkout", "-q", "main"]) + gitIn(repoDir, ["branch", "-D", "legacy-tmp"]) + + const stub = startSurfaceStub({ openPrs: [{ number: 7, head: "docs/auto-sync/cli", body: "" }] }) + try { + const outputFile = path.join(root, "gh-output") + const result = runNodeScript(PREP_SCRIPT, { + cwd: repoDir, + env: { + GITHUB_REPOSITORY: "acme/repo", + GH_TOKEN: "stub-token", + DOCS_SYNC_API_BASE: `http://127.0.0.1:${stub.port}`, + GITHUB_OUTPUT: outputFile, + }, + }) + assert.equal(result.status, 0, `prepare-branch must exit 0: ${result.output}`) + + const remote = gitIn(repoDir, ["ls-remote", "--heads", "origin", "docs/auto-sync"]) + assert.equal(remote, "", `the lingering legacy remote branch must be deleted; got:\n${remote}`) + assert.throws( + () => gitIn(repoDir, ["rev-parse", "--verify", "refs/remotes/origin/docs/auto-sync"]), + "the stale remote-tracking ref must be removed", + ) + } finally { + stub.child.kill() + } +} + +// --------------------------------------------------------------------------- +// Case 19 — watermark pick and processed-through refresh helpers +// --------------------------------------------------------------------------- +function case19_watermarkAndMarkerHelpers() { + console.log("case 19 — watermark pick and processed-through refresh") + + const mk = (iso, number) => ({ + number, + user: { login: "github-actions[bot]" }, + body: `x\n\n`, + }) + + assert.equal(pickWatermark([]), null) + assert.equal( + pickWatermark([ + { number: 1, user: { login: "human" }, body: "" }, + ]), + null, + "an untrusted author's marker must be ignored", + ) + + // The search is updated-desc, so the first trusted entry is the latest run's + // marker even when a later entry carries a larger (stale) one. + const picked = pickWatermark([mk("2026-08-01T00:00:00.000Z", 2), mk("2026-09-01T00:00:00.000Z", 1)]) + assert.equal(picked.number, 2) + assert.equal(picked.marker.toISOString(), "2026-08-01T00:00:00.000Z") + + // patchProcessedThrough replaces in place, appends when absent, exactly one marker. + const replaced = patchProcessedThrough( + "body\n\n", + "new", + ) + assert.ok(replaced.includes("")) + assert.ok(!replaced.includes("processed-through 2020")) + assert.equal((replaced.match(/")) + + // Anti-drift: the watermark query orders by updated, and upsert refreshes the + // marker on a skipped surface's open PR. + const wmSrc = fs.readFileSync(path.join(HERE, "watermark.mjs"), "utf8") + assert.ok(wmSrc.includes("sort:updated-desc"), "watermark query must sort by updated-desc") + const upsertSrc = fs.readFileSync(UPSERT_SCRIPT, "utf8") + assert.ok( + /patchProcessedThrough\(openPr\.body/.test(upsertSrc), + "upsert must refresh a skipped surface's processed-through marker", + ) +} + +// --------------------------------------------------------------------------- +// Case 20 — a skipped surface's open PR gets the current marker +// --------------------------------------------------------------------------- +function case20_upsertRefreshesSkippedMarker() { + console.log("case 20 — a skipped surface's open PR gets the current marker") + + const stub = startSurfaceStub({ + openPrs: [ + { + number: 42, + head: `${SURFACE_PREFIX}gateway`, + body: "gateway body\n\n\n", + }, + ], + }) + try { + const { root, repoDir } = setupSurfaceRepo() + // Make cli the only changed surface: the gateway PR has no changed files + // this run and would otherwise keep its stale markers. + for (const name of ["vscode", "gateway", "other"]) { + fs.rmSync(path.join(repoDir, SURFACE_PAGE_FILES[name])) + } + + const learned = "" + const result = runUpsert(repoDir, root, stub.port, { LEARNED_THROUGH: learned }) + assert.equal(result.status, 0, `upsert must exit 0: ${result.output}`) + + const patched = readStubLog(stub.logFile).find((e) => e.method === "PATCH" && e.url.includes("/pulls/42")) + assert.ok(patched, "the skipped gateway PR must be PATCHed with the current marker") + assert.ok( + patched.body.body.includes(``), + `the skipped surface's marker must advance; got ${patched.body.body}`, + ) + assert.ok(!patched.body.body.includes("processed-through 2020"), "the stale marker must be replaced") + assert.ok(patched.body.body.includes(learned), "the learned-through marker must also advance") + assert.ok(!patched.body.body.includes("commit=oldtip"), "the stale learned-through marker must be replaced") + } finally { + stub.child.kill() + } +} + +// --------------------------------------------------------------------------- +// Case 21 — a fixture that spans two product surfaces plus one unmapped file +// --------------------------------------------------------------------------- +// The owner request's acceptance case: two product surfaces plus one unmapped +// file must produce exactly three PRs with the right assignees and reviewers, +// and no changed file may appear in two PRs. This case also prints the proof a +// PR body quotes: the computed surface map, the two assignees chosen per +// surface, the reviewers requested, and the title of each PR the run opens. +function case21_twoSurfacesOneUnmapped() { + console.log("case 21 — two product surfaces plus one unmapped file") + const stub = startSurfaceStub({ + openPrs: [], + commits: { + "packages/opencode/": [stubCommit("alice", 1), stubCommit("bob", 5)], + "packages/kilo-vscode/": [stubCommit("erin", 1), stubCommit("frank", 3)], + }, + permissions: { alice: "write", bob: "write", erin: "write", frank: "write" }, + }) + try { + const { root, repoDir } = setupSurfaceRepo() + // Narrow the fixture to the two product surfaces (cli, vscode) plus the + // unmapped community page: drop the third product surface's file. + fs.rmSync(path.join(repoDir, SURFACE_PAGE_FILES.gateway)) + + const result = runUpsert(repoDir, root, stub.port) + assert.equal(result.status, 0, `upsert must exit 0: ${result.output}`) + + const entries = readStubLog(stub.logFile) + const creates = entries.filter((e) => e.kind === "create") + assert.equal( + creates.length, + 3, + `exactly three PRs must be created; got ${JSON.stringify(creates)}\n${result.output}`, + ) + const byHead = new Map(creates.map((c) => [c.head, c])) + assert.deepEqual([...byHead.keys()].sort(), [ + `${SURFACE_PREFIX}cli`, + `${SURFACE_PREFIX}other`, + `${SURFACE_PREFIX}vscode`, + ]) + + const assignees = entries.filter((e) => e.method === "POST" && e.url.endsWith("/assignees")) + const reviews = entries.filter((e) => e.method === "POST" && e.url.endsWith("/requested_reviewers")) + const pairFor = (name) => { + const create = byHead.get(`${SURFACE_PREFIX}${name}`) + const a = assignees.find((e) => e.url.includes(`/issues/${create.number}/`)) + const r = reviews.find((e) => e.url.includes(`/pulls/${create.number}/`)) + assert.ok(a, `assignee POST for ${name}`) + assert.ok(r, `reviewer POST for ${name}`) + return { assignees: a.body.assignees, reviewers: r.body.reviewers } + } + assert.deepEqual(pairFor("cli"), { assignees: ["alice", "bob"], reviewers: ["alice", "bob"] }) + assert.deepEqual(pairFor("vscode"), { assignees: ["erin", "frank"], reviewers: ["erin", "frank"] }) + assert.deepEqual(pairFor("other"), { + assignees: ["lambertjosh", "intentionally-left-nil"], + reviewers: ["lambertjosh", "intentionally-left-nil"], + }) + assert.match(byHead.get(`${SURFACE_PREFIX}other`).body.body, /fixed reviewers/i) + + // Each surface branch carries exactly its own changed file; no file twice. + const branchFiles = new Map() + for (const name of ["cli", "vscode", "other"]) { + const head = `${SURFACE_PREFIX}${name}` + const diff = gitIn(repoDir, ["diff", "--name-only", "origin/main", `origin/${head}`]) + branchFiles.set(name, diff.split("\n").filter(Boolean)) + } + assert.deepEqual(branchFiles.get("cli"), [SURFACE_PAGE_FILES.cli]) + assert.deepEqual(branchFiles.get("vscode"), [SURFACE_PAGE_FILES.vscode]) + assert.deepEqual(branchFiles.get("other"), [SURFACE_PAGE_FILES.other]) + const flat = [...branchFiles.values()].flat() + assert.equal(flat.length, 3, "three changed files total") + assert.equal(new Set(flat).size, 3, "no changed file may appear in two PRs") + + // The proof a PR body quotes, from this run. + console.log(" computed surface map (changed file -> surface):") + for (const [name, files] of branchFiles) { + for (const file of files) console.log(` ${file} -> ${name}`) + } + for (const name of ["cli", "vscode", "other"]) { + const create = byHead.get(`${SURFACE_PREFIX}${name}`) + const pair = pairFor(name) + console.log(` pull request: ${create.head}`) + console.log(` title: ${create.body.title}`) + console.log(` assignees: ${pair.assignees.join(", ")}`) + console.log(` requested reviewers: ${pair.reviewers.join(", ")}`) + } + } finally { + stub.child.kill() + } +} + +// Write a docs page that routes to a cloud surface. The mobile page +// (packages/kilo-docs/pages/code-with-ai/platforms/mobile.md) routes to +// cloud-mobile, whose reviewers come from Kilo-Org/cloud history. +function addCloudPage(repoDir, rel) { + const p = path.join(repoDir, rel) + fs.mkdirSync(path.dirname(p), { recursive: true }) + fs.writeFileSync(p, "# new\n") +} + +const MOBILE_PAGE = "packages/kilo-docs/pages/code-with-ai/platforms/mobile.md" + +// The create entry plus the assignee/reviewer POSTs for one surface head. +function createdPair(entries, head) { + const create = entries.filter((e) => e.kind === "create").find((c) => c.head === head) + assert.ok(create, `${head} PR must be created`) + const a = entries.find( + (e) => e.method === "POST" && e.url.endsWith("/assignees") && e.url.includes(`/issues/${create.number}/`), + ) + const r = entries.find( + (e) => e.method === "POST" && e.url.endsWith("/requested_reviewers") && e.url.includes(`/pulls/${create.number}/`), + ) + assert.ok(a, `assignee POST for ${head}`) + assert.ok(r, `reviewer POST for ${head}`) + return { create, assignees: a.body.assignees, reviewers: r.body.reviewers } +} + +function case22_cloudSurfacePrFromCloudHistory() { + console.log("case 22 — a cloud docs page ranks reviewers from the cloud repo") + const stub = startSurfaceStub({ + openPrs: [], + commits: { "Kilo-Org/cloud|apps/mobile/": [stubCommit("nina", 1), stubCommit("omar", 2)] }, + permissions: { nina: "write", omar: "write" }, + }) + try { + const { root, repoDir } = setupSurfaceRepo() + addCloudPage(repoDir, MOBILE_PAGE) + + const result = runUpsert(repoDir, root, stub.port, { CLOUD_REPO_TOKEN: "cloud-token" }) + assert.equal(result.status, 0, `upsert must exit 0: ${result.output}`) + + const entries = readStubLog(stub.logFile) + const history = entries.find( + (e) => e.method === "GET" && e.url.startsWith("/repos/Kilo-Org/cloud/commits?path=apps%2Fmobile%2F"), + ) + assert.ok(history, `cloud history must be read through the API; got ${JSON.stringify(entries.map((e) => e.url))}`) + assert.equal(history.auth, "Bearer cloud-token", "the cloud history must be read with the cloud token") + + const { create, assignees, reviewers } = createdPair(entries, `${SURFACE_PREFIX}cloud-mobile`) + assert.deepEqual(assignees, ["nina", "omar"], "cloud-mobile assignees come from cloud-repo history") + assert.deepEqual(reviewers, ["nina", "omar"], "cloud-mobile reviewers come from cloud-repo history") + assert.match(create.body.body, /ranked from `Kilo-Org\/cloud` git history over `apps\/mobile\/`/) + assert.ok(create.body.body.includes("Kilo-Org/cloud"), "the PR body must name the cloud repo") + assert.ok(create.body.body.includes("CROSS_REPO_ACCESS_TOKEN"), "the PR body must name the cloud token secret") + console.log(` cloud-mobile PR reviewers: ${reviewers.join(", ")}`) + } finally { + stub.child.kill() + } +} + +function case23_cloudHistoryUnreachableFallsBack() { + console.log("case 23 — an unreachable cloud history still opens the surface PR") + const stub = startSurfaceStub({ + openPrs: [], + commits: { "Kilo-Org/cloud|apps/mobile/": [stubCommit("nina", 1)] }, + permissions: { nina: "write" }, + failCommits: ["Kilo-Org/cloud|apps/mobile/"], + }) + try { + const { root, repoDir } = setupSurfaceRepo() + addCloudPage(repoDir, MOBILE_PAGE) + + const result = runUpsert(repoDir, root, stub.port, { CLOUD_REPO_TOKEN: "cloud-token" }) + assert.equal(result.status, 0, `run must exit 0: ${result.output}`) + + const { create, reviewers } = createdPair(readStubLog(stub.logFile), `${SURFACE_PREFIX}cloud-mobile`) + assert.deepEqual( + reviewers, + ["lambertjosh", "intentionally-left-nil"], + "an unreachable cloud history must fall back to the fixed other pair", + ) + assert.match(create.body.body, /fixed `other` reviewers/i, "the body must name the fallback") + assert.ok(create.body.body.includes("CROSS_REPO_ACCESS_TOKEN"), "the body must name the required secret") + assert.ok(create.body.body.includes("Kilo-Org/cloud"), "the body must name the cloud repo") + console.log(` unreachable cloud history -> reviewers: ${reviewers.join(", ")}`) + } finally { + stub.child.kill() + } +} + +// --------------------------------------------------------------------------- +// Case 24 — a dated legacy PR and its branch are ignored end to end +// --------------------------------------------------------------------------- +function case24_legacyDatedPrIgnored() { + console.log("case 24 — a dated legacy auto-sync PR is ignored, not reused or counted") + const root = mktemp("docs-sync-legacy-dated-") + const originDir = path.join(root, "origin.git") + gitIn(root, ["init", "--bare", "origin.git"]) + const repoDir = path.join(root, "repo") + fs.mkdirSync(repoDir) + initRepoWithIdentity(repoDir) + + const write = (rel, text) => { + const p = path.join(repoDir, rel) + fs.mkdirSync(path.dirname(p), { recursive: true }) + fs.writeFileSync(p, text) + } + write("packages/kilo-docs/pages/getting-started/base.md", "# base\n") + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", "base"]) + gitIn(repoDir, ["remote", "add", "origin", originDir]) + gitIn(repoDir, ["push", "-q", "origin", "main"]) + + // Durability integration branch: the base prepare-branch must choose. + gitIn(repoDir, ["checkout", "-q", "-b", "docs/auto-sync-integration"]) + write("packages/kilo-docs/pages/getting-started/integ.md", "# integ\n") + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", "integ"]) + gitIn(repoDir, ["push", "-q", "origin", "docs/auto-sync-integration"]) + + // A real surface branch and its open PR. + gitIn(repoDir, ["checkout", "-q", "-b", "docs/auto-sync/cli", "main"]) + write("packages/kilo-docs/pages/getting-started/cli-surface.md", "# cli surface\n") + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", "cli surface"]) + gitIn(repoDir, ["push", "-q", "origin", "docs/auto-sync/cli"]) + + // A dated legacy branch carrying a file that is not on main. + gitIn(repoDir, ["checkout", "-q", "-b", "legacy-tmp", "main"]) + write("packages/kilo-docs/pages/getting-started/dated.md", "# dated legacy\n") + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", "dated legacy"]) + gitIn(repoDir, ["push", "-q", "origin", "HEAD:refs/heads/docs/auto-sync-2026-09-11"]) + gitIn(repoDir, ["checkout", "-q", "main"]) + gitIn(repoDir, ["branch", "-D", "legacy-tmp"]) + // A ref may not be a path prefix of another, so the local surface branch must + // be gone for prepare-branch to check out the bare integration ref. + gitIn(repoDir, ["branch", "-D", "docs/auto-sync/cli"]) + + const openPrs = [ + { number: 14043, head: "docs/auto-sync-2026-09-11", body: "" }, + { number: 77, head: "docs/auto-sync/cli", body: "" }, + ] + + const prepStub = startSurfaceStub({ openPrs }) + try { + const outputFile = path.join(root, "gh-output") + const result = runNodeScript(PREP_SCRIPT, { + cwd: repoDir, + env: { + GITHUB_REPOSITORY: "acme/repo", + GH_TOKEN: "stub-token", + DOCS_SYNC_API_BASE: `http://127.0.0.1:${prepStub.port}`, + GITHUB_OUTPUT: outputFile, + }, + }) + assert.equal(result.status, 0, `prepare-branch must exit 0: ${result.output}`) + const out = fs.readFileSync(outputFile, "utf8") + assert.match(out, /branch=docs\/auto-sync\n/, "integration branch output") + assert.match(out, /mode=update\n/, "mode output") + + const prepLog = readStubLog(prepStub.logFile) + assert.ok( + !prepLog.some((e) => e.url.includes("/issues/14043/comments")), + "prepare-branch must not comment on the dated legacy PR", + ) + assert.ok( + !prepLog.some((e) => e.method === "PATCH" && e.url.includes("/pulls/14043")), + "prepare-branch must not close the dated legacy PR", + ) + assert.ok( + !fs.existsSync(path.join(repoDir, "packages/kilo-docs/pages/getting-started/dated.md")), + "the dated branch's file must not be reused as an integration base", + ) + assert.ok( + fs.existsSync(path.join(repoDir, "packages/kilo-docs/pages/getting-started/cli-surface.md")), + "the cli surface branch must be merged into the integration tree", + ) + } finally { + prepStub.child.kill() + } + + // Upsert against only the dated legacy PR still open: the dated head is not + // counted as an existing surface PR, so the cli surface gets a fresh PR. + write("packages/kilo-docs/pages/getting-started/new.md", "# new\n") + const upsertStub = startSurfaceStub({ openPrs: [{ number: 14043, head: "docs/auto-sync-2026-09-11", body: "" }] }) + try { + const result = runUpsert(repoDir, root, upsertStub.port) + assert.equal(result.status, 0, `upsert must exit 0: ${result.output}`) + + const log = readStubLog(upsertStub.logFile) + assert.ok( + !log.some((e) => e.method === "PATCH" && e.url.includes("/pulls/14043")), + "upsert must not PATCH the dated legacy PR", + ) + const creates = log.filter((e) => e.kind === "create") + assert.ok( + creates.some((c) => c.head === `${SURFACE_PREFIX}cli`), + `a fresh cli surface PR must be created; got ${JSON.stringify(creates.map((c) => c.head))}`, + ) + } finally { + upsertStub.child.kill() + } +} + +// --------------------------------------------------------------------------- +// Case 25 — only `docs/auto-sync/` heads are surface branches +// --------------------------------------------------------------------------- +function case25_surfaceBranchRecognized() { + console.log("case 25 — surface branches are recognized, dated and bare refs are not") + assert.equal(isSurfaceBranch("docs/auto-sync/cli"), true, "a surface branch is recognized") + assert.equal(surfaceNameFromBranch("docs/auto-sync/vscode"), "vscode", "the surface name is derived") + assert.equal(isSurfaceBranch("docs/auto-sync-2026-09-11"), false, "a dated legacy branch is not a surface") + assert.equal(isSurfaceBranch("docs/auto-sync"), false, "the bare integration ref is not a surface") + assert.equal(isSurfaceBranch("docs/auto-sync/"), false, "an empty surface name is not a surface") + console.log(" dated and bare auto-sync refs are not surface branches") +} + +// --------------------------------------------------------------------------- +// Case 26/27 — learn.mjs against the live API path +// --------------------------------------------------------------------------- + +/** + * Repo with a bare origin, `main`, and one bot-authored commit per branch. The + * only commits past main are the sync job's own, so every branch yields no + * candidate correction and the run takes the direct marker-PATCH route. + */ +function setupLearnLiveRepo(branches) { + const root = mktemp("docs-sync-learn-live-") + const originDir = path.join(root, "origin.git") + gitIn(root, ["init", "--bare", "origin.git"]) + const repoDir = path.join(root, "repo") + fs.mkdirSync(repoDir) + initRepoWithIdentity(repoDir) + + const write = (rel, text) => { + const p = path.join(repoDir, rel) + fs.mkdirSync(path.dirname(p), { recursive: true }) + fs.writeFileSync(p, text) + } + write("packages/kilo-docs/LEARNINGS.md", renderLearnings([])) + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", "base"]) + gitIn(repoDir, ["remote", "add", "origin", originDir]) + gitIn(repoDir, ["push", "-q", "origin", "main"]) + gitIn(repoDir, ["update-ref", "refs/remotes/origin/main", "main"]) + + for (const branch of branches) { + gitIn(repoDir, ["checkout", "-q", "-b", branch, "main"]) + write(`packages/kilo-docs/pages/${branch.replaceAll("/", "-")}.md`, `# ${branch}\n`) + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", `docs ${branch}`, "--author", `github-actions[bot] <${GITHUB_BOT_EMAIL}>`]) + gitIn(repoDir, ["push", "-q", "origin", branch]) + gitIn(repoDir, ["update-ref", `refs/remotes/origin/${branch}`, branch]) + gitIn(repoDir, ["checkout", "-q", "main"]) + gitIn(repoDir, ["branch", "-D", branch]) + } + fs.mkdirSync(path.join(repoDir, "docs-sync-out"), { recursive: true }) + return { root, repoDir } +} + +function runLearnLive(repoDir, root, port) { + return runNodeScript(LEARN_SCRIPT, { + cwd: repoDir, + env: { + TRIAGE_MODEL: "test/model", + GITHUB_REPOSITORY: "acme/repo", + GH_TOKEN: "stub-token", + DOCS_SYNC_API_BASE: `http://127.0.0.1:${port}`, + GITHUB_OUTPUT: path.join(root, "gh-output"), + GITHUB_STEP_SUMMARY: path.join(root, "gh-summary"), + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + }, + }) +} + +// --------------------------------------------------------------------------- +// Case 26 — a legacy dated auto-sync PR is not a learnings target +// --------------------------------------------------------------------------- +function case26_learnIgnoresLegacyPr() { + console.log("case 26 — learn.mjs ignores a legacy dated auto-sync PR") + const { root, repoDir } = setupLearnLiveRepo(["docs/auto-sync/cli", "docs/auto-sync-2026-09-11"]) + + const stub = startSurfaceStub({ + openPrs: [ + { number: 14043, head: "docs/auto-sync-2026-09-11", body: "" }, + { number: 77, head: "docs/auto-sync/cli", body: "" }, + ], + }) + try { + const result = runLearnLive(repoDir, root, stub.port) + assert.equal(result.status, 0, `learn.mjs must exit 0: ${result.output}`) + + const log = readStubLog(stub.logFile) + assert.ok( + !log.some((e) => e.url.includes("/pulls/14043/comments")), + "the legacy dated PR must not be read as a learning target", + ) + assert.ok( + !log.some((e) => e.method === "PATCH" && e.url.includes("/pulls/14043")), + "the legacy dated PR body must not be PATCHed", + ) + assert.ok( + log.some((e) => e.method === "PATCH" && e.url.includes("/pulls/77")), + "the surface PR must still get its marker refreshed", + ) + } finally { + stub.child.kill() + } +} + +// --------------------------------------------------------------------------- +// Case 27 — one failed marker PATCH does not skip the remaining targets +// --------------------------------------------------------------------------- +function case27_patchFailureIsolated() { + console.log("case 27 — one failed marker PATCH does not abort the rest") + const { root, repoDir } = setupLearnLiveRepo(["docs/auto-sync/cli", "docs/auto-sync/vscode"]) + + const stub = startSurfaceStub({ + openPrs: [ + { number: 1, head: "docs/auto-sync/cli", body: "" }, + { number: 2, head: "docs/auto-sync/vscode", body: "" }, + ], + failPatch: [1], + }) + try { + const result = runLearnLive(repoDir, root, stub.port) + assert.equal(result.status, 0, `a failed marker PATCH must not fail the run: ${result.output}`) + + const log = readStubLog(stub.logFile) + assert.ok( + log.some((e) => e.method === "PATCH" && e.url.includes("/pulls/2")), + "the remaining target's marker must still be published", + ) + } finally { + stub.child.kill() + } } // --------------------------------------------------------------------------- @@ -3475,6 +4645,22 @@ function main() { case9_reverts, case10_learnings, case11_prOwner, + case12_surfaceSegmentation, + case13_surfaceFailureIsolated, + case14_prepareBranchSurfaces, + case15_surfaceUpdate, + case16_surfaceDeletion, + case17_learnAllSurfaces, + case18_legacyRefDeletedWithoutPr, + case19_watermarkAndMarkerHelpers, + case20_upsertRefreshesSkippedMarker, + case21_twoSurfacesOneUnmapped, + case22_cloudSurfacePrFromCloudHistory, + case23_cloudHistoryUnreachableFallsBack, + case24_legacyDatedPrIgnored, + case25_surfaceBranchRecognized, + case26_learnIgnoresLegacyPr, + case27_patchFailureIsolated, ] let failed = 0 for (const fn of cases) { diff --git a/.github/docs-sync/surfaces.json b/.github/docs-sync/surfaces.json new file mode 100644 index 0000000000..4bc95ddd31 --- /dev/null +++ b/.github/docs-sync/surfaces.json @@ -0,0 +1,74 @@ +{ + "derivation": "Derived from the repository layout. A product surface is a package under packages/ that ships a distinct client, plugin, backend, or hosted service: cli = packages/opencode/ + packages/tui/ + packages/server/ + packages/sdk/ + packages/plugin/; vscode = packages/kilo-vscode/ + packages/kilo-web-ui/ + packages/kilo-ui/; jetbrains = packages/kilo-jetbrains/; gateway = packages/kilo-gateway/; web = packages/kilo-console/ + packages/kilo-indexing/ + packages/kilo-memory/ + packages/kilo-sandbox/. Docs route from the IA tree packages/kilo-docs/pages/ plus docs/jetbrains-vscode-settings-parity.md: each surface lists the pages sections that document it, and the per-platform pages under packages/kilo-docs/pages/code-with-ai/platforms/ map to the matching extension surface (the vscode/ directory to vscode, jetbrains.md to jetbrains). A doc path belongs to the surface with the longest matching prefix; a path that matches none of those prefixes falls to `other` (the explicit other prefixes are listed under other.docs). The cloud surfaces are derived the same way from the Kilo-Org/cloud layout: cloud-mobile = apps/mobile/, cloud-web = apps/web/, cloud-extension = apps/extension/, and cloud-agent = the cloud-agent packages under packages/ (packages/cloud-agent-sdk/ + packages/cloud-agent-profile/). A cloud source names its repository while a bare string still means this repository. The pages under packages/kilo-docs/pages/collaborate/ document the cloud web app (app.kilo.ai: teams dashboard, billing, SSO, adoption dashboard), so they route to cloud-web. No page under packages/kilo-docs/pages/ documents the browser side-panel extension yet, so cloud-extension lists no docs prefix.", + "other": { + "name": "other", + "reviewers": ["lambertjosh", "intentionally-left-nil"], + "docs": [ + "packages/kilo-docs/pages/community/", + "packages/kilo-docs/pages/kiloclaw/", + "packages/kilo-docs/pages/contributing/", + "packages/kilo-docs/LEARNINGS.md", + "docs/" + ] + }, + "surfaces": [ + { + "name": "cli", + "sources": ["packages/opencode/", "packages/tui/", "packages/server/", "packages/sdk/", "packages/plugin/"], + "docs": [ + "packages/kilo-docs/pages/getting-started/", + "packages/kilo-docs/pages/code-with-ai/", + "packages/kilo-docs/pages/customize/", + "packages/kilo-docs/pages/automate/" + ] + }, + { + "name": "vscode", + "sources": ["packages/kilo-vscode/", "packages/kilo-web-ui/", "packages/kilo-ui/"], + "docs": ["packages/kilo-docs/pages/code-with-ai/platforms/vscode/"] + }, + { + "name": "jetbrains", + "sources": ["packages/kilo-jetbrains/"], + "docs": ["packages/kilo-docs/pages/code-with-ai/platforms/jetbrains.md"] + }, + { + "name": "gateway", + "sources": ["packages/kilo-gateway/"], + "docs": ["packages/kilo-docs/pages/gateway/", "packages/kilo-docs/pages/ai-providers/"] + }, + { + "name": "web", + "sources": [ + "packages/kilo-console/", + "packages/kilo-indexing/", + "packages/kilo-memory/", + "packages/kilo-sandbox/" + ], + "docs": ["packages/kilo-docs/pages/api/", "packages/kilo-docs/pages/deploy-secure/"] + }, + { + "name": "cloud-mobile", + "sources": [{ "repo": "Kilo-Org/cloud", "prefix": "apps/mobile/" }], + "docs": ["packages/kilo-docs/pages/code-with-ai/platforms/mobile.md"] + }, + { + "name": "cloud-web", + "sources": [{ "repo": "Kilo-Org/cloud", "prefix": "apps/web/" }], + "docs": ["packages/kilo-docs/pages/collaborate/"] + }, + { + "name": "cloud-extension", + "sources": [{ "repo": "Kilo-Org/cloud", "prefix": "apps/extension/" }], + "docs": [] + }, + { + "name": "cloud-agent", + "sources": [ + { "repo": "Kilo-Org/cloud", "prefix": "packages/cloud-agent-sdk/" }, + { "repo": "Kilo-Org/cloud", "prefix": "packages/cloud-agent-profile/" } + ], + "docs": ["packages/kilo-docs/pages/code-with-ai/platforms/cloud-agent.md"] + } + ] +} diff --git a/.github/docs-sync/surfaces.mjs b/.github/docs-sync/surfaces.mjs new file mode 100644 index 0000000000..022e418040 --- /dev/null +++ b/.github/docs-sync/surfaces.mjs @@ -0,0 +1,202 @@ +// kilocode_change - new file + +/** + * Committed product-surface map for the docs-sync bot. + * + * The surfaces are data, not a list hard-coded in a function: this module only + * knows how to read `.github/docs-sync/surfaces.json` and answer which surface + * a doc path or source path belongs to. Edit the JSON to change the map. + * + * A doc path belongs to at most one surface. `surfaceForDoc` and + * `surfaceForSource` therefore return exactly one name: the surface whose + * configured prefix is the longest match (ties broken by file order in + * `surfaces.json`), or `other` when nothing matches. Longest-match lets a + * specific prefix (the per-platform `code-with-ai/platforms/vscode/` pages) + * beat the broad `code-with-ai/` prefix owned by `cli`. `other` is always last + * in `surfaceNames` so PR creation/reporting is deterministic. + */ + +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const HERE = path.dirname(fileURLToPath(import.meta.url)) + +/** Absolute path to the committed surface map. */ +export const SURFACE_MAP_PATH = path.join(HERE, "surfaces.json") + +/** Name of the catch-all surface. */ +export const OTHER = "other" + +/** + * Branch prefix for a per-surface docs-sync PR. The rolling integration branch + * is exactly `docs/auto-sync` (no trailing segment) and has no PR of its own, + * so a head that starts with this prefix is a product/`other` surface PR. + */ +export const SURFACE_BRANCH_PREFIX = "docs/auto-sync/" + +/** Branch name for a surface PR. */ +export function surfaceBranch(name) { + return `${SURFACE_BRANCH_PREFIX}${name}` +} + +/** The prefix that marks a per-surface branch. */ +export function surfaceBranchPrefix() { + return SURFACE_BRANCH_PREFIX +} + +/** + * The surface name encoded in a per-surface branch, or `null` when `ref` is not + * one. A surface branch must start with `SURFACE_BRANCH_PREFIX` and carry a + * non-empty segment after it, so the bare integration branch `docs/auto-sync` + * and a legacy dated branch `docs/auto-sync-2026-09-11` both return `null`. + */ +export function surfaceNameFromBranch(ref) { + const head = String(ref ?? "") + if (!head.startsWith(SURFACE_BRANCH_PREFIX)) return null + const name = head.slice(SURFACE_BRANCH_PREFIX.length) + return name.length > 0 ? name : null +} + +/** True when `ref` is one of this job's per-surface branches. */ +export function isSurfaceBranch(ref) { + return surfaceNameFromBranch(ref) !== null +} + +/** Normalize a repo-relative path for prefix matching. */ +function norm(file) { + return String(file ?? "") + .replace(/\\/g, "/") + .replace(/^\.\//, "") + .replace(/^\/+/, "") +} + +function names(map) { + return [...(map?.surfaces ?? []).map((s) => s.name), map?.other?.name ?? OTHER] +} + +/** + * Load the surface map from JSON. Defaults to the committed map; the `file` + * argument exists so tests can point at a copy. + */ +export function loadSurfaceMap(file = SURFACE_MAP_PATH) { + return JSON.parse(fs.readFileSync(file, "utf8")) +} + +/** Surface names in file order with `other` last. */ +export function surfaceNames(map) { + return names(map) +} + +/** Doc path prefixes assigned to `name` (empty for `other` unless configured). */ +export function surfaceDocPrefixes(name, map) { + if (name === (map?.other?.name ?? OTHER)) return map?.other?.docs ?? [] + return (map?.surfaces ?? []).find((s) => s.name === name)?.docs ?? [] +} + +/** + * Normalize one `sources` entry to `{ prefix, repo }`. + * + * A bare string is a prefix in THIS repository; an object keeps its `repo` + * (`repo: null` or missing also means this repository). This is what lets the + * committed bare-string `sources` keep working while a cloud surface names the + * repository whose git history ranks its reviewers. + */ +export function sourceEntry(entry) { + if (typeof entry === "string") return { prefix: entry, repo: null } + return { prefix: entry?.prefix, repo: entry?.repo ?? null } +} + +/** Normalized `{ prefix, repo }` source entries for `name` (empty for `other`). */ +export function surfaceSourceEntries(name, map) { + if (name === (map?.other?.name ?? OTHER)) return [] + const sources = (map?.surfaces ?? []).find((s) => s.name === name)?.sources ?? [] + return sources.map(sourceEntry) +} + +/** Unique non-null source repos for `name`, in first-seen order (empty for `other`). */ +export function surfaceSourceRepos(name, map) { + const repos = [] + for (const entry of surfaceSourceEntries(name, map)) { + if (entry.repo && !repos.includes(entry.repo)) repos.push(entry.repo) + } + return repos +} + +/** + * Source path prefixes used for git-history ranking (empty for `other`). + * Kept as a string list so callers that only print or match prefixes are + * unaffected by repo-qualified entries; use `surfaceSourceEntries` for the + * repo each prefix belongs to. + */ +export function surfaceSourcePrefixes(name, map) { + return surfaceSourceEntries(name, map).map((e) => e.prefix) +} + +/** Configured reviewers for `name`; product surfaces compute theirs at runtime. */ +export function surfaceReviewers(name, map) { + if (name === (map?.other?.name ?? OTHER)) return map?.other?.reviewers ?? [] + return (map?.surfaces ?? []).find((s) => s.name === name)?.reviewers ?? [] +} + +/** The explicit doc prefixes that fall to `other`, for printing in PR bodies. */ +export function otherDocPrefixes(map) { + return map?.other?.docs ?? [] +} + +/** The derivation sentence, for printing in PR bodies. */ +export function derivation(map) { + return map?.derivation ?? "" +} + +/** + * Exactly one surface name for a path: the surface whose configured prefix in + * `key` ("docs" or "sources") is the longest match, ties broken by surface + * order in the map, falling back to `other` when no prefix matches. + */ +function longestMatch(file, map, key) { + const f = norm(file) + let name = map?.other?.name ?? OTHER + let len = -1 + for (const s of map?.surfaces ?? []) { + for (const raw of s[key] ?? []) { + const p = norm(sourceEntry(raw).prefix) + if (p.length > len && f.startsWith(p)) { + name = s.name + len = p.length + } + } + } + return name +} + +/** Exactly one surface name for a doc path. */ +export function surfaceForDoc(file, map = loadSurfaceMap()) { + return longestMatch(file, map, "docs") +} + +/** Exactly one surface name for a source path. */ +export function surfaceForSource(file, map = loadSurfaceMap()) { + return longestMatch(file, map, "sources") +} + +/** + * Group files by the surface their doc path belongs to. Preserves input order + * inside each group; groups are ordered by `surfaceNames` (file order, `other` + * last). Empty groups are omitted. + */ +export function groupBySurface(files, map) { + const order = surfaceNames(map) + const buckets = new Map(order.map((name) => [name, []])) + for (const file of Array.isArray(files) ? files : []) { + const name = surfaceForDoc(file, map) + const bucket = buckets.get(name) + if (bucket) bucket.push(file) + } + const out = new Map() + for (const name of order) { + const bucket = buckets.get(name) + if (bucket && bucket.length > 0) out.set(name, bucket) + } + return out +} diff --git a/.github/docs-sync/surfaces.test.mjs b/.github/docs-sync/surfaces.test.mjs new file mode 100644 index 0000000000..205fb1601e --- /dev/null +++ b/.github/docs-sync/surfaces.test.mjs @@ -0,0 +1,388 @@ +// kilocode_change - new file + +/** + * Unit tests for the docs-sync surface map and reviewer ranking. + * + * Fixtures are created in a temp dir at runtime (no committed fixtures) so the + * tests prove the map is data-driven rather than baked into code. + * Run: node .github/docs-sync/surfaces.test.mjs + */ + +import assert from "node:assert/strict" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import test, { after } from "node:test" + +import { + SURFACE_MAP_PATH, + OTHER, + loadSurfaceMap, + surfaceNames, + surfaceForDoc, + surfaceForSource, + surfaceDocPrefixes, + surfaceSourcePrefixes, + surfaceSourceRepos, + surfaceReviewers, + isSurfaceBranch, + surfaceNameFromBranch, + otherDocPrefixes, + derivation, + groupBySurface, +} from "./surfaces.mjs" +import { rankContributors, computeSurfaceReviewers } from "./reviewers.mjs" + +const temps = [] + +function writeMap(map) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "docs-sync-surfaces-")) + temps.push(dir) + const file = path.join(dir, "surfaces.json") + fs.writeFileSync(file, JSON.stringify(map, null, 2)) + return file +} + +after(() => { + for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true }) +}) + +const map = loadSurfaceMap() +const NOW = Date.parse("2026-06-01T00:00:00Z") +const daysAgo = (n) => new Date(NOW - n * 86_400_000).toISOString() + +test("committed map is well formed", () => { + assert.ok(fs.existsSync(SURFACE_MAP_PATH)) + assert.equal(path.basename(SURFACE_MAP_PATH), "surfaces.json") + assert.ok(derivation(map).length > 0) + assert.equal(surfaceNames(map).at(-1), OTHER) + assert.deepEqual(surfaceNames(map), [ + "cli", + "vscode", + "jetbrains", + "gateway", + "web", + "cloud-mobile", + "cloud-web", + "cloud-extension", + "cloud-agent", + "other", + ]) + assert.deepEqual(surfaceReviewers(OTHER, map), ["lambertjosh", "intentionally-left-nil"]) +}) + +test("every configured prefix resolves to exactly its own surface", () => { + for (const surface of map.surfaces) { + for (const prefix of surfaceDocPrefixes(surface.name, map)) { + assert.equal(surfaceForDoc(`${prefix}index.md`, map), surface.name, `doc ${prefix}`) + assert.equal(surfaceForDoc(`${prefix}nested/deep.md`, map), surface.name, `doc ${prefix}`) + } + for (const prefix of surfaceSourcePrefixes(surface.name, map)) { + assert.equal(surfaceForSource(`${prefix}src/index.ts`, map), surface.name, `source ${prefix}`) + } + } +}) + +test("platform pages route to the matching extension surface", () => { + assert.equal(surfaceForDoc("packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md", map), "vscode") + assert.equal(surfaceForDoc("packages/kilo-docs/pages/code-with-ai/platforms/vscode/whats-new.md", map), "vscode") + assert.equal(surfaceForDoc("packages/kilo-docs/pages/code-with-ai/platforms/jetbrains.md", map), "jetbrains") + assert.equal(surfaceForDoc("packages/kilo-docs/pages/code-with-ai/platforms/cli.md", map), "cli") + assert.equal(surfaceForDoc("packages/kilo-docs/pages/code-with-ai/features/autocomplete.md", map), "cli") + // Longest prefix wins: the specific platform prefix beats the broad cli one. + assert.equal(surfaceForDoc("packages/kilo-docs/pages/code-with-ai/platforms/vscode/nested/deep.md", map), "vscode") +}) + +test("cloud doc pages route to the cloud surfaces, not other", () => { + assert.equal(surfaceForDoc("packages/kilo-docs/pages/code-with-ai/platforms/mobile.md", map), "cloud-mobile") + assert.equal(surfaceForDoc("packages/kilo-docs/pages/code-with-ai/platforms/cloud-agent.md", map), "cloud-agent") + // collaborate/ documents the cloud web app, so it no longer routes to cli. + assert.equal(surfaceForDoc("packages/kilo-docs/pages/collaborate/teams/dashboard.md", map), "cloud-web") + assert.equal(surfaceForDoc("packages/kilo-docs/pages/collaborate/billing/usage.md", map), "cloud-web") +}) + +test("cloud sources are repo-qualified while bare strings still mean this repo", () => { + assert.equal(surfaceForSource("apps/web/src/index.ts", map), "cloud-web") + assert.equal(surfaceForSource("apps/mobile/src/index.ts", map), "cloud-mobile") + assert.equal(surfaceForSource("apps/extension/src/panel.ts", map), "cloud-extension") + assert.equal(surfaceForSource("packages/cloud-agent-sdk/src/index.ts", map), "cloud-agent") + // The committed bare-string entries keep resolving to this repository. + assert.equal(surfaceForSource("packages/opencode/src/index.ts", map), "cli") + assert.equal(surfaceForSource("packages/kilo-gateway/src/index.ts", map), "gateway") + + assert.deepEqual(surfaceSourceRepos("cloud-web", map), ["Kilo-Org/cloud"]) + assert.deepEqual(surfaceSourceRepos("cloud-agent", map), ["Kilo-Org/cloud"]) + assert.deepEqual(surfaceSourceRepos("gateway", map), []) + assert.deepEqual(surfaceSourceRepos(OTHER, map), []) +}) + +test("isSurfaceBranch separates surface branches from the integration and dated branches", () => { + assert.equal(isSurfaceBranch("docs/auto-sync/cli"), true) + assert.equal(surfaceNameFromBranch("docs/auto-sync/cli"), "cli") + assert.equal(isSurfaceBranch("docs/auto-sync/other"), true) + assert.equal(isSurfaceBranch("docs/auto-sync-2026-09-11"), false) + assert.equal(surfaceNameFromBranch("docs/auto-sync-2026-09-11"), null) + assert.equal(isSurfaceBranch("docs/auto-sync"), false) + assert.equal(surfaceNameFromBranch("docs/auto-sync"), null) + assert.equal(isSurfaceBranch("docs/auto-sync/"), false) + assert.equal(surfaceNameFromBranch("docs/auto-sync/"), null) + assert.equal(isSurfaceBranch("main"), false) + assert.equal(surfaceNameFromBranch(undefined), null) +}) + +test("unmatched doc and source paths fall to other", () => { + assert.equal(surfaceForDoc("packages/kilo-docs/pages/community/index.md", map), OTHER) + assert.equal(surfaceForDoc("packages/kilo-docs/pages/kiloclaw/index.md", map), OTHER) + assert.equal(surfaceForDoc("packages/kilo-docs/pages/contributing/index.md", map), OTHER) + assert.equal(surfaceForDoc("packages/kilo-docs/LEARNINGS.md", map), OTHER) + assert.equal(surfaceForDoc("packages/kilo-docs/pages/never-seen/new.md", map), OTHER) + assert.equal(surfaceForDoc("packages/kilo-docs/pages/index.tsx", map), OTHER) + assert.equal(surfaceForDoc("docs/jetbrains-vscode-settings-parity.md", map), OTHER) + assert.equal(surfaceForSource("packages/kilo-telemetry/src/index.ts", map), OTHER) + assert.equal(surfaceForDoc("README.md", map), OTHER) + + assert.ok(otherDocPrefixes(map).includes("packages/kilo-docs/pages/community/")) + assert.ok(otherDocPrefixes(map).includes("docs/")) +}) + +test("the map is read from data: changing a prefix changes the answer", () => { + assert.equal(surfaceForDoc("packages/kilo-docs/pages/getting-started/index.md", map), "cli") + + const changed = structuredClone(map) + const cli = changed.surfaces.find((s) => s.name === "cli") + cli.docs = cli.docs.map((p) => + p === "packages/kilo-docs/pages/getting-started/" ? "packages/kilo-docs/pages/renamed/" : p, + ) + const changedFile = loadSurfaceMap(writeMap(changed)) + assert.equal(surfaceForDoc("packages/kilo-docs/pages/getting-started/index.md", changedFile), OTHER) + assert.equal(surfaceForDoc("packages/kilo-docs/pages/renamed/index.md", changedFile), "cli") + + const extended = structuredClone(map) + extended.surfaces.push({ + name: "extra", + sources: ["packages/kilo-extra/"], + docs: ["packages/kilo-docs/pages/extra/"], + }) + const extendedFile = loadSurfaceMap(writeMap(extended)) + assert.equal(surfaceForDoc("packages/kilo-docs/pages/extra/index.md", extendedFile), "extra") + assert.equal(surfaceForSource("packages/kilo-extra/src/index.ts", extendedFile), "extra") + assert.equal(surfaceForDoc("packages/kilo-docs/pages/extra/index.md", map), OTHER) +}) + +test("groupBySurface partitions files with no duplicates in deterministic order", () => { + const files = [ + "packages/kilo-docs/pages/community/index.md", + "packages/kilo-docs/pages/gateway/index.md", + "packages/kilo-docs/pages/getting-started/index.md", + "packages/kilo-docs/pages/unknown/a.md", + ] + const groups = groupBySurface(files, map) + assert.deepEqual([...groups.keys()], ["cli", "gateway", "other"]) + assert.deepEqual(groups.get("other"), [ + "packages/kilo-docs/pages/community/index.md", + "packages/kilo-docs/pages/unknown/a.md", + ]) + assert.deepEqual(groups.get("cli"), ["packages/kilo-docs/pages/getting-started/index.md"]) + const flat = [...groups.values()].flat() + assert.equal(flat.length, files.length) + assert.equal(new Set(flat).size, files.length) +}) + +test("rankContributors weights recent over old and applies the half-life", () => { + const commits = [ + { login: "recent", type: "User", date: daysAgo(10) }, + ...Array.from({ length: 4 }, () => ({ login: "old", type: "User", date: daysAgo(1500) })), + { login: "dependabot[bot]", type: "Bot", date: daysAgo(1) }, + { login: "renovate-bot", type: "Bot", date: daysAgo(1) }, + { login: "human-bot[bot]", type: "User", date: daysAgo(1) }, + ] + const ranked = rankContributors(commits, NOW) + assert.deepEqual( + ranked.map((r) => r.login), + ["recent", "old"], + ) + assert.equal(ranked[0].count, 1) + assert.equal(ranked[1].count, 4) + assert.ok(ranked[0].score > ranked[1].score) + + const fresh = rankContributors([{ login: "x", type: "User", date: daysAgo(0) }], NOW) + assert.ok(Math.abs(fresh[0].score - 1) < 1e-9) + const halved = rankContributors([{ login: "x", type: "User", date: daysAgo(180) }], NOW) + assert.ok(Math.abs(halved[0].score - 0.5) < 1e-9) + const faster = rankContributors([{ login: "x", type: "User", date: daysAgo(30) }], NOW, { halfLifeDays: 30 }) + assert.ok(Math.abs(faster[0].score - 0.5) < 1e-9) +}) + +test("rankContributors orders deterministically on score ties", () => { + const ranked = rankContributors( + [ + { login: "bob", type: "User", date: daysAgo(5) }, + { login: "alice", type: "User", date: daysAgo(5) }, + { login: "carol", type: "User", date: daysAgo(5) }, + ], + NOW, + ) + assert.deepEqual( + ranked.map((r) => r.login), + ["alice", "bob", "carol"], + ) +}) + +test("other reviewers are the fixed pair and never hit the API", async () => { + let calls = 0 + const result = await computeSurfaceReviewers(OTHER, { + api: async () => { + calls++ + return [] + }, + repo: "acme/kilo", + now: NOW, + map, + }) + assert.deepEqual(result.reviewers, ["lambertjosh", "intentionally-left-nil"]) + assert.equal(calls, 0) + assert.deepEqual(result.sourcePrefixes, []) + assert.match(result.note, /fixed reviewers/) +}) + +test("product-surface reviewers come from ranked commits that have write access", async () => { + const seen = [] + const api = async (p) => { + seen.push(p) + if (p.includes("/commits?")) { + return [ + { author: { login: "zoe", type: "User" }, commit: { author: { date: daysAgo(2) } } }, + { author: { login: "bob", type: "User" }, commit: { author: { date: daysAgo(3) } } }, + { author: { login: "nope", type: "User" }, commit: { author: { date: daysAgo(4) } } }, + { author: { login: "carl", type: "User" }, commit: { author: { date: daysAgo(5) } } }, + { author: { login: "dependabot[bot]", type: "Bot" }, commit: { author: { date: daysAgo(1) } } }, + ] + } + if (p.endsWith("/collaborators/zoe/permission")) return { permission: "read" } + if (p.endsWith("/collaborators/bob/permission")) return { permission: "write" } + if (p.endsWith("/collaborators/nope/permission")) return { permission: "none" } + if (p.endsWith("/collaborators/carl/permission")) return { role_name: "admin" } + throw new Error(`unexpected ${p}`) + } + + const result = await computeSurfaceReviewers("gateway", { api, repo: "acme/kilo", now: NOW, map }) + assert.deepEqual(result.reviewers, ["bob", "carl"]) + assert.deepEqual(result.sourcePrefixes, surfaceSourcePrefixes("gateway", map)) + assert.ok(seen.some((p) => p === "/repos/acme/kilo/commits?path=packages%2Fkilo-gateway%2F&per_page=100")) + assert.match(result.note, /packages\/kilo-gateway\//) + assert.match(result.note, /half-life 180 days/) + assert.match(result.note, /write/) +}) + +test("a 404 permission skips the candidate and keeps walking", async () => { + const api = async (p) => { + if (p.includes("/commits?")) { + return [ + { author: { login: "ghost", type: "User" }, commit: { author: { date: daysAgo(1) } } }, + { author: { login: "real", type: "User" }, commit: { author: { date: daysAgo(2) } } }, + ] + } + if (p.includes("/ghost/")) { + const err = new Error("Not Found") + err.status = 404 + throw err + } + return { permission: "write" } + } + const result = await computeSurfaceReviewers("cli", { api, repo: "acme/kilo", now: NOW, map }) + assert.deepEqual(result.reviewers, ["real"]) +}) + +test("API or token failure yields no reviewers and a named reason", async () => { + const api = async () => { + throw new Error("GH_TOKEN (or GITHUB_TOKEN) is required") + } + const result = await computeSurfaceReviewers("gateway", { api, repo: "acme/kilo", now: NOW, map }) + assert.deepEqual(result.reviewers, []) + assert.match(result.note, /GH_TOKEN/) +}) + +test("a missing repository is named instead of guessed", async () => { + const result = await computeSurfaceReviewers("gateway", { api: async () => [], map }) + assert.deepEqual(result.reviewers, []) + assert.match(result.note, /GITHUB_REPOSITORY/) +}) + +test("cloud reviewers are ranked from the named repo's history with its token", async () => { + const seen = [] + const api = async (p, opts) => { + seen.push({ p, opts }) + if (p === "/repos/Kilo-Org/cloud/commits?path=apps%2Fweb%2F&per_page=100") { + return [ + { author: { login: "zoe", type: "User" }, commit: { author: { date: daysAgo(2) } } }, + { author: { login: "bob", type: "User" }, commit: { author: { date: daysAgo(3) } } }, + ] + } + if (p === "/repos/Kilo-Org/cloud/collaborators/zoe/permission") return { permission: "read" } + if (p === "/repos/Kilo-Org/cloud/collaborators/bob/permission") return { permission: "write" } + throw new Error(`unexpected ${p}`) + } + + const result = await computeSurfaceReviewers("cloud-web", { + api, + repo: "Kilo-Org/kilo", + now: NOW, + map, + cloudToken: "cloud-token", + }) + assert.deepEqual(result.reviewers, ["bob"]) + assert.equal(result.fallback, undefined) + assert.deepEqual(result.sourcePrefixes, ["apps/web/"]) + const commits = seen.find((c) => c.p.includes("/commits?")) + assert.equal(commits.p, "/repos/Kilo-Org/cloud/commits?path=apps%2Fweb%2F&per_page=100") + assert.deepEqual(commits.opts, { auth: "cloud-token" }) + const perm = seen.find((c) => c.p.endsWith("/collaborators/bob/permission")) + assert.equal(perm.p, "/repos/Kilo-Org/cloud/collaborators/bob/permission") + assert.deepEqual(perm.opts, { auth: "cloud-token" }) + assert.match(result.note, /ranked from `Kilo-Org\/cloud` git history over `apps\/web\/`/) +}) + +test("an unreachable cloud history falls back to the fixed other pair, never throwing", async () => { + const api = async () => { + throw new Error("403: Resource not accessible by integration") + } + const result = await computeSurfaceReviewers("cloud-web", { + api, + repo: "Kilo-Org/kilo", + now: NOW, + map, + cloudToken: "cloud-token", + }) + assert.deepEqual(result.reviewers, ["lambertjosh", "intentionally-left-nil"]) + assert.equal(result.fallback, true) + assert.deepEqual(result.sourcePrefixes, ["apps/web/"]) + assert.match(result.note, /Kilo-Org\/cloud/) + assert.match(result.note, /fixed `other` reviewers/) + assert.match(result.note, /CROSS_REPO_ACCESS_TOKEN/) + assert.match(result.note, /CLOUD_REPO_TOKEN/) +}) + +test("a cloud surface with no token falls back before making any call", async () => { + const saved = process.env.CLOUD_REPO_TOKEN + delete process.env.CLOUD_REPO_TOKEN + let calls = 0 + try { + const api = async () => { + calls++ + return [] + } + const result = await computeSurfaceReviewers("cloud-mobile", { + api, + repo: "Kilo-Org/kilo", + now: NOW, + map, + }) + assert.equal(calls, 0) + assert.deepEqual(result.reviewers, ["lambertjosh", "intentionally-left-nil"]) + assert.equal(result.fallback, true) + assert.match(result.note, /CROSS_REPO_ACCESS_TOKEN/) + } finally { + if (saved !== undefined) process.env.CLOUD_REPO_TOKEN = saved + else delete process.env.CLOUD_REPO_TOKEN + } +}) diff --git a/.github/docs-sync/triage.mjs b/.github/docs-sync/triage.mjs index eb68084651..87b2fde8e3 100644 --- a/.github/docs-sync/triage.mjs +++ b/.github/docs-sync/triage.mjs @@ -11,7 +11,8 @@ * docs_worthy:false so filter-worthy excludes it) so the watermark holds * back and the next run re-collects those PRs. * - * Env: TRIAGE_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (gateway auth, set by + * Env: TRIAGE_MODEL (provider/model), DOCS_SYNC_VARIANT (reasoning effort, default max), + * KILO_API_KEY + KILO_ORG_ID (gateway auth, set by * the workflow; the kilo provider reads them natively). Reads the prompt from triage-prompt.md next to this script. * Budget: TRIAGE_BUDGET_MINUTES (default 35). Test hook: DOCS_SYNC_BACKOFF_MS. */ @@ -20,7 +21,15 @@ import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" import { parseTriageEntries } from "./extract-json.mjs" -import { appendSummary, backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs" +import { + appendSummary, + backoffMsForAttempt, + deadline, + remainingMs, + REASONING_VARIANT, + runKilo, + sleepSync, +} from "./lib.mjs" import { readLearningsBlock } from "./learn.mjs" const CHUNK_SIZE = 25 @@ -78,7 +87,19 @@ function triageChunk(chunk, index, budgetDeadline) { // permission.bash map via KILO_CONFIG_CONTENT should replace --auto once the // required shell patterns are stable (see PR #12605 review thread). const result = runKilo({ - args: ["run", "--auto", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile], + args: [ + "run", + "--auto", + prompt, + "-m", + model, + "--variant", + REASONING_VARIANT, + "--dir", + process.cwd(), + "-f", + chunkFile, + ], timeoutMs: Math.min(CHUNK_TIMEOUT_MS, left), streamStdout: false, label: `triage chunk ${index} attempt ${attempt}`, diff --git a/.github/docs-sync/upsert-pr.mjs b/.github/docs-sync/upsert-pr.mjs index d4b7d2a21f..a7a1481f4e 100644 --- a/.github/docs-sync/upsert-pr.mjs +++ b/.github/docs-sync/upsert-pr.mjs @@ -1,13 +1,16 @@ // kilocode_change - new file /** - * Commits the agent's packages/kilo-docs changes, pushes the rolling branch, - * and creates or updates the rolling auto-docs PR. + * Commits the agent's packages/kilo-docs changes, pushes the rolling + * integration branch, and maintains one auto-docs PR per product surface + * (plus one `other` PR). * - * No-op when the agent produced no docs changes. PRs become drafts when the - * diff exceeds the file cap or verification failed. The PR body carries - * marker-delimited sections so later runs can append rows, plus a - * machine-readable processed-through watermark. + * The integration branch (`BRANCH`, normally `docs/auto-sync`) accumulates the + * whole docs tree and has no PR of its own. Every surface PR is rebuilt in a + * throwaway worktree that carries only that surface's changed files, so a file + * belongs to exactly one PR. Per-surface git and API work is caught: one + * surface's failure warns and continues and cannot lose another surface's + * changes or stall the watermark. * * Watermark invariant: processed-through never moves past a PR that has no * terminal outcome. Terminal := action !== "pending" (a deliberate agent @@ -20,22 +23,43 @@ import { execFileSync } from "node:child_process" import fs from "node:fs" +import os from "node:os" +import path from "node:path" import { pathToFileURL } from "node:url" +import { + OTHER, + derivation, + groupBySurface, + loadSurfaceMap, + otherDocPrefixes, + surfaceBranch, + surfaceDocPrefixes, + surfaceForDoc, + surfaceNameFromBranch, + surfaceNames, + surfaceSourceEntries, +} from "./surfaces.mjs" +import { computeSurfaceReviewers } from "./reviewers.mjs" + +export { surfaceBranch } const BRANCH = process.env.BRANCH || "docs/auto-sync" +// Git refs cannot hold both `docs/auto-sync` and `docs/auto-sync/`: a +// ref may not be a path prefix of another ref. Surface PRs therefore use the +// required `docs/auto-sync/` heads, and the integration tree is pushed +// to this sibling ref purely for durability (a failed surface cannot lose the +// run's changes). +const INTEGRATION_BRANCH = process.env.INTEGRATION_BRANCH || "docs/auto-sync-integration" const FILE_CAP = 15 const ROW_CAP = 150 const PENDING_DISPLAY_CAP = 60 const SUMMARY_FILE = ".docs-sync-summary.json" -// Owner of the rolling docs PR: assigned and asked for review on creation. -const DOCS_OWNER = "emilieschario" const DOCS_PATH = "packages/kilo-docs" +const MAP_FILE = ".github/docs-sync/surfaces.json" export const LEARNINGS_FILE = "packages/kilo-docs/LEARNINGS.md" -const git = (args) => - execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }) - .toString() - .trim() +const git = (args, cwd) => + execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "inherit"] }).toString().trim() // Agent-generated strings land in the PR body next to machine-read markers. // Strip HTML-comment sequences so a crafted/adversarial value cannot forge @@ -100,6 +124,7 @@ export function renderBody({ verified, draftReasons, note, + surfaceBlock = "", }) { const pendingDisplay = pendingRows.length > PENDING_DISPLAY_CAP @@ -113,7 +138,7 @@ This PR keeps kilo.ai/docs in sync with features merged to [Kilo-Org/cloud](http - Window: \`${since}\` → \`${through}\` - Verification (docs build + tests): **${verified ? "passing" : "FAILING — needs a human look"}** ${note ? `- ${note}\n` : ""}${draftReasons.length > 0 ? `- Draft because: ${draftReasons.join("; ")}\n` : ""} -### Changes +${surfaceBlock ? `${surfaceBlock}\n\n` : ""}### Changes ${section("changes", "| Docs change | Source |", changesRows)} @@ -334,6 +359,29 @@ export function resolveLearnedThrough({ envValue, prBody }) { return m ? m[0] : "" } +/** + * Replace the processed-through marker in an existing PR body. Used to refresh + * an open surface PR whose surface produced no changed files this run. Without + * it that PR keeps an older marker; since the watermark is read from the latest + * auto-docs PR, a skipped surface could regress or pin it. + */ +export function patchProcessedThrough(body, through) { + const marker = `` + const re = // + const b = String(body ?? "") + if (re.test(b)) return b.replace(re, marker) + return b + `\n${marker}\n` +} + +/** Replace the learned-through marker in an existing PR body. No-op for an empty marker. */ +export function patchLearnedThrough(body, marker) { + if (!marker) return String(body ?? "") + const re = // + const b = String(body ?? "") + if (re.test(b)) return b.replace(re, marker) + return b + `\n${marker}\n` +} + /** * No-diff early-return report. Returns summary markdown and an optional * replay warning. Warns IFF sinceOverride && uncovered non-empty (no commit @@ -361,17 +409,258 @@ export function noDiffReport({ uncovered, sinceOverride }) { return { summary, warning } } +/** PR title for a surface. */ +export function prTitle(name, date) { + return `docs: auto-sync ${name} with merged PRs (through ${date})` +} + +/** + * The per-surface block appended to the rolling body. It states the surface, + * the derivation and the map file, the computed surface map, the source/doc + * prefixes (a repo-qualified prefix prints its repository), the paths that fall + * to `other`, and how the two reviewers were computed (or why they were not). + * When any source entry names another repository, it states the token the + * workflow needs and where it is set. + */ +export function surfaceBlock({ name, map, reviewers, note }) { + const other = map?.other?.name ?? OTHER + const sources = surfaceSourceEntries(name, map) + const docs = surfaceDocPrefixes(name, map) + const otherPaths = otherDocPrefixes(map) + const list = (prefixes) => (prefixes.length > 0 ? prefixes.map((p) => `\`${p}\``).join(", ") : "_none_") + const sourceList = + sources.length > 0 + ? sources.map((e) => (e.repo ? `\`${e.prefix}\` (${e.repo})` : `\`${e.prefix}\``)).join(", ") + : "_none_" + const repos = [...new Set(sources.map((e) => e.repo).filter(Boolean))] + const pair = reviewers.length > 0 ? reviewers.map((r) => `@${r}`).join(" and ") : "_none computed_" + return [ + `### Surface: \`${name}\``, + "", + `- Assignees / requested reviewers: ${pair}`, + `- Derivation: ${derivation(map)}`, + `- Map: \`${MAP_FILE}\``, + `- Surface map: ${list(surfaceNames(map))}`, + `- Source prefixes: ${sourceList}`, + `- Doc prefixes: ${list(docs)}`, + `- Paths that fall to \`${other}\`: ${list(otherPaths)}`, + ...(repos.length > 0 + ? [ + `- Reviewers are ranked from ${list(repos)}; the workflow needs a token with \`contents: read\` on that repository (repository secret \`CROSS_REPO_ACCESS_TOKEN\`, exposed to the upsert step as \`CLOUD_REPO_TOKEN\`).`, + ] + : []), + `- How the two were computed: ${note}`, + ].join("\n") +} + +/** + * Open auto-docs PRs keyed by surface name. A PR is a surface PR only when its + * head is a `docs/auto-sync/` branch naming one of `names`; a legacy + * dated head (`docs/auto-sync-`) and the bare integration ref are not. + */ +async function openSurfacePrs({ api, searchIssues, repo, names }) { + const prs = await searchIssues(`repo:${repo} is:pr is:open label:auto-docs`, { maxPages: 2 }) + const byName = new Map() + for (const item of prs) { + let detail + try { + detail = await api(`/repos/${repo}/pulls/${item.number}`) + } catch (err) { + console.warn(`::warning::docs-sync: could not read auto-docs PR #${item.number}: ${err.message}`) + continue + } + const name = surfaceNameFromBranch(detail?.head?.ref) + if (name !== null && names.includes(name)) byName.set(name, detail) + } + return byName +} + +/** + * Split a surface's changed files by whether they still exist at + * `integrationSha`. A path deleted in the integration tree has no blob there, + * so `git checkout integrationSha -- ` fails with `pathspec ... did not + * match any file(s) known to git`, and checkout can only restore paths, never + * remove one. Deletions must be removed from the worktree instead. + */ +function partitionByPresence(git, sha, files) { + if (files.length === 0) return { present: [], removed: [] } + const names = new Set(git(["ls-tree", "-r", "--name-only", sha, "--", ...files]).split("\n").filter(Boolean)) + return { present: files.filter((f) => names.has(f)), removed: files.filter((f) => !names.has(f)) } +} + +/** + * Build the surface branch in a throwaway worktree and push it. On update the + * base is the open PR's remote branch (so human commits survive), merged with + * origin/main; otherwise it is a fresh branch from origin/main. + */ +function buildSurfaceBranch({ name, files, branch, integrationSha, update, date }) { + try { + git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]) + } catch { + // Branch may not exist yet; --force-with-lease will create it. + } + const base = update ? `origin/${branch}` : "origin/main" + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "docs-sync-wt-")) + try { + git(["worktree", "add", "--detach", tmp, base]) + if (update) { + // Preserve human commits on the open PR branch. + git(["merge", "origin/main", "--no-edit"], tmp) + } + // Remove deletions before restoring the rest, so a file→directory change + // cannot block the checkout of a path under the removed file. + const { present, removed } = partitionByPresence(git, integrationSha, files) + if (removed.length > 0) git(["rm", "-rf", "--ignore-unmatch", "--", ...removed], tmp) + if (present.length > 0) { + git(["checkout", integrationSha, "--", ...present], tmp) + git(["add", "--", ...present], tmp) + } + const dirty = git(["status", "--porcelain"], tmp) + if (dirty !== "") { + git(["commit", "-m", `docs: sync ${name} with merged PRs (${date})`], tmp) + } else { + console.log(`surface ${name}: no file delta over ${base}; refreshing PR body only`) + } + git( + update + ? ["push", "origin", `HEAD:refs/heads/${branch}`] + : ["push", "--force-with-lease", "origin", `HEAD:refs/heads/${branch}`], + tmp, + ) + } finally { + try { + git(["worktree", "remove", "--force", tmp]) + } catch (err) { + console.warn(`::warning::docs-sync: could not remove worktree ${tmp}: ${err.message}`) + } + try { + fs.rmSync(tmp, { recursive: true, force: true }) + } catch (err) { + console.warn(`::warning::docs-sync: could not delete worktree dir ${tmp}: ${err.message}`) + } + try { + git(["worktree", "prune"]) + } catch (err) { + console.warn(`::warning::docs-sync: could not prune worktrees: ${err.message}`) + } + } +} + +/** Create or update one surface PR. Throws on failure; the caller isolates it. */ +async function upsertSurface({ + name, + files, + map, + api, + repo, + now, + date, + since, + through, + verified, + rows, + openPr, + integrationSha, +}) { + const branch = surfaceBranch(name) + const reviewed = await computeSurfaceReviewers(name, { api, repo, now, map }) + + const draftReasons = [] + if (files.length > FILE_CAP) draftReasons.push(`diff exceeds ${FILE_CAP} files (${files.length})`) + if (!verified) draftReasons.push("docs build/tests not passing") + const nonContent = nonContentFiles(files) + if (nonContent.length > 0) { + const listed = nonContent + .slice(0, 5) + .map((f) => clean(f).replaceAll("|", "\\|")) + .join(", ") + draftReasons.push(`touches non-content files outside pages/ and lib/nav/: ${listed}`) + } + const draft = draftReasons.length > 0 + + const update = Boolean(openPr) + buildSurfaceBranch({ name, files, branch, integrationSha, update, date }) + + // Append-only: carry rows already on the surface PR body forward. + let oldChanges = [] + let oldSkipped = [] + let prBody = "" + if (update) { + prBody = openPr.body ?? "" + oldChanges = extractSectionRows(prBody, "changes") + oldSkipped = dropLegacySkipped(extractSectionRows(prBody, "skipped")) + } + const learnedThrough = resolveLearnedThrough({ envValue: process.env.LEARNED_THROUGH, prBody }) + + const body = renderBody({ + date, + since, + through, + learnedThrough, + changesRows: mergeRows(oldChanges, rows.changesRows), + pendingRows: rows.pendingRows, + skippedRows: mergeRows(oldSkipped, rows.skippedRows), + verified, + draftReasons, + note: "", + surfaceBlock: surfaceBlock({ name, map, reviewers: reviewed.reviewers, note: reviewed.note }), + }) + console.log(`--- ${name} PR body ---\n${body}\n--- end ${name} PR body ---`) + + let pr + if (update) { + pr = await api(`/repos/${repo}/pulls/${openPr.number}`, { + method: "PATCH", + body: { title: prTitle(name, date), body }, + }) + } else { + pr = await api(`/repos/${repo}/pulls`, { + method: "POST", + body: { title: prTitle(name, date), head: branch, base: "main", body, draft }, + }) + await api(`/repos/${repo}/issues/${pr.number}/labels`, { method: "POST", body: { labels: ["auto-docs"] } }) + } + // Best effort: the PR already exists here, so a non-collaborator, an author + // login, or a revoked account must not fail the surface. + if (reviewed.reviewers.length > 0) { + try { + await api(`/repos/${repo}/issues/${pr.number}/assignees`, { + method: "POST", + body: { assignees: reviewed.reviewers }, + }) + await api(`/repos/${repo}/pulls/${pr.number}/requested_reviewers`, { + method: "POST", + body: { reviewers: reviewed.reviewers }, + }) + } catch (err) { + console.warn(`::warning::docs-sync: could not assign or request review for ${name}: ${err.message}`) + } + } + + return { + name, + url: pr.html_url, + number: pr.number, + branch, + reviewers: reviewed.reviewers, + draft, + created: !update, + } +} + async function main() { - const { api, appendOutput, appendSummary, repo } = await import("./lib.mjs") + const { api, appendOutput, appendSummary, repo, searchIssues } = await import("./lib.mjs") const now = process.env.PROCESSED_THROUGH ?? new Date().toISOString() const since = process.env.SINCE ?? "unknown" const sinceOverride = process.env.SINCE_OVERRIDE === "true" const mode = ["update", "conflict"].includes(process.env.PREP_MODE) ? process.env.PREP_MODE : "fresh" - const existingPr = process.env.PR_NUMBER || "" const verified = process.env.VERIFIED === "true" const date = now.slice(0, 10) + // The surface map is data, loaded once for the whole run. + const map = loadSurfaceMap() + // The agent's run summary is consumed here and never committed. const agentSummary = readJson(SUMMARY_FILE, []) fs.rmSync(SUMMARY_FILE, { force: true }) @@ -395,81 +684,25 @@ async function main() { // before any commit-creating step, including prepare-branch's merge. git(["add", DOCS_PATH]) git(["commit", "-m", `docs: sync with merged PRs (${date})`]) + const integrationSha = git(["rev-parse", "HEAD"]) // Watermark: now when fully covered; else earliest uncovered merged_at − 1ms. // Pass SINCE as fallback so missing digest-full cannot strand uncovered PRs. + // Computed once (global); per-surface failures never touch it. const through = computeProcessedThrough({ uncovered, digest, now, fallback: since }) - // The draft cap bounds the cumulative PR diff, not just this run's commit. + // Commit the full diff to the integration branch and push it first, so a + // single surface's later failure cannot lose the run's changes. A conflicted + // run pushes its dated fallback branch; a normal run pushes the durability + // ref (see INTEGRATION_BRANCH). const changedFiles = git(["diff", "--name-only", "origin/main...HEAD", "--", DOCS_PATH]).split("\n").filter(Boolean) - const draftReasons = [] - if (changedFiles.length > FILE_CAP) draftReasons.push(`diff exceeds ${FILE_CAP} files (${changedFiles.length})`) - if (!verified) draftReasons.push("docs build/tests not passing") - // Content gate: legitimate bot edits are docs pages and nav files. Anything - // else in the docs package (build config, components, tests) executes - // during the verify build, so force human review before merge. - const nonContent = nonContentFiles(changedFiles) - if (nonContent.length > 0) { - // File paths are agent-chosen; sanitize before they land in the PR body. - const listed = nonContent - .slice(0, 5) - .map((f) => clean(f).replaceAll("|", "\\|")) - .join(", ") - draftReasons.push(`touches non-content files outside pages/ and lib/nav/: ${listed}`) - } - const draft = draftReasons.length > 0 - + const durableBranch = mode === "conflict" ? BRANCH : INTEGRATION_BRANCH git( mode === "update" - ? ["push", "origin", `HEAD:${BRANCH}`] - : ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`], + ? ["push", "origin", `HEAD:refs/heads/${durableBranch}`] + : ["push", "--force-with-lease", "origin", `HEAD:refs/heads/${durableBranch}`], ) - const { - changesRows: changesNew, - pendingRows: pendingNew, - skippedRows: skippedNew, - } = routeRows({ - summary: agentSummary, - triage, - uncovered, - }) - - let oldChanges = [] - let oldSkipped = [] - let oldPending = [] - let prBody = "" - if (mode === "update" && existingPr) { - const pr = await api(`/repos/${repo()}/pulls/${existingPr}`) - prBody = pr.body ?? "" - oldChanges = extractSectionRows(pr.body, "changes") - oldSkipped = dropLegacySkipped(extractSectionRows(pr.body, "skipped")) - oldPending = extractSectionRows(pr.body, "pending") - } - - // Pending is replaced each run (informational only); do not merge legacy - // pending rows — uncovered is recomputed fresh. oldPending is read only so - // extractSectionRows stays exercised; discarded deliberately. - void oldPending - - const learnedThrough = resolveLearnedThrough({ envValue: process.env.LEARNED_THROUGH, prBody }) - - const body = renderBody({ - date, - since, - through, - learnedThrough, - changesRows: mergeRows(oldChanges, changesNew), - pendingRows: pendingNew, - skippedRows: mergeRows(oldSkipped, skippedNew), - verified, - draftReasons, - note: - mode === "conflict" && existingPr - ? `Continues from #${existingPr}, whose branch conflicted with \`main\` (its commits are preserved there).` - : "", - }) - try { await api(`/repos/${repo()}/labels`, { method: "POST", @@ -479,65 +712,82 @@ async function main() { if (err.status !== 422) throw err // 422 = label already exists } - let prNumber - let prUrl - if (mode === "update" && existingPr) { - const pr = await api(`/repos/${repo()}/pulls/${existingPr}`, { - method: "PATCH", - body: { title: `docs: auto-sync with merged PRs (through ${date})`, body }, - }) - prNumber = pr.number - prUrl = pr.html_url - await api(`/repos/${repo()}/issues/${prNumber}/comments`, { - method: "POST", - body: { - body: `(bot) Appended changes processed through \`${through}\`. Verification: **${verified ? "passing" : "failing"}**.${draft ? ` Draft because: ${draftReasons.join("; ")}.` : ""}`, - }, - }) - } else { - const pr = await api(`/repos/${repo()}/pulls`, { - method: "POST", - body: { - title: `docs: auto-sync with merged PRs (through ${date})`, - head: BRANCH, - base: "main", - body, - draft, - }, - }) - prNumber = pr.number - prUrl = pr.html_url - await api(`/repos/${repo()}/issues/${prNumber}/labels`, { method: "POST", body: { labels: ["auto-docs"] } }) - // Best effort: the PR already exists here, so a non-collaborator or a - // revoked account must not fail the run. + const rows = routeRows({ summary: agentSummary, triage, uncovered }) + const groups = groupBySurface(changedFiles, map) + + // Run-log proof: the computed surface for every changed file. + console.log(`docs-sync surface map (${changedFiles.length} changed files):`) + for (const file of changedFiles) console.log(` ${surfaceForDoc(file, map)} <- ${file}`) + + let open = new Map() + try { + open = await openSurfacePrs({ api, searchIssues, repo: repo(), names: surfaceNames(map) }) + } catch (err) { + console.warn(`::warning::docs-sync: could not list open surface PRs; creating fresh branches: ${err.message}`) + } + + const results = [] + for (const name of surfaceNames(map)) { + const files = groups.get(name) + if (!files || files.length === 0) continue try { - await api(`/repos/${repo()}/issues/${prNumber}/assignees`, { - method: "POST", - body: { assignees: [DOCS_OWNER] }, - }) - await api(`/repos/${repo()}/pulls/${prNumber}/requested_reviewers`, { - method: "POST", - body: { reviewers: [DOCS_OWNER] }, + const result = await upsertSurface({ + name, + files, + map, + api, + repo: repo(), + now, + date, + since, + through, + verified, + rows, + openPr: open.get(name) ?? null, + integrationSha, }) + results.push(result) + console.log(`surface ${name}: ${result.created ? "created" : "updated"} ${result.url} reviewers=${result.reviewers.join(",") || "none"}`) } catch (err) { - console.warn(`::warning::docs-sync: could not assign or request review from ${DOCS_OWNER}: ${err.message}`) + results.push({ name, error: err }) + console.warn(`::warning::docs-sync: surface ${name} failed: ${err.message}`) } - if (mode === "conflict" && existingPr) { - await api(`/repos/${repo()}/issues/${existingPr}/comments`, { - method: "POST", - body: { - body: `(bot) This branch conflicted with \`main\`, so the sync continues in ${prUrl}. Commits on this branch are preserved — please close this PR after the new one is reviewed.`, - }, - }) + } + + // A surface with no changed files is skipped above, so its open PR keeps its + // previous processed-through marker. Refresh that marker to this run's value + // so a stale marker on a skipped surface cannot regress or pin the watermark + // (which is read from the latest auto-docs PR). + for (const name of surfaceNames(map)) { + const files = groups.get(name) + if (files && files.length > 0) continue + const openPr = open.get(name) + if (!openPr) continue + try { + const learned = resolveLearnedThrough({ envValue: process.env.LEARNED_THROUGH, prBody: openPr.body ?? "" }) + let body = patchProcessedThrough(openPr.body ?? "", through) + if (learned) body = patchLearnedThrough(body, learned) + await api(`/repos/${repo()}/pulls/${openPr.number}`, { method: "PATCH", body: { body } }) + console.log(`surface ${name}: refreshed markers on #${openPr.number}`) + } catch (err) { + console.warn(`::warning::docs-sync: could not refresh the processed-through marker for ${name}: ${err.message}`) } } - appendOutput("pr_url", prUrl) - appendSummary( - `### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n- uncovered: ${uncovered.length}\n- processed-through: ${through}\n`, - ) + const summaryLines = ["### docs-sync surface PRs", ""] + for (const r of results) { + summaryLines.push(r.error ? `- **${r.name}**: failed — ${clean(r.error.message)}` : `- **${r.name}**: ${r.url}`) + } + summaryLines.push("", `- changed files: ${changedFiles.length}`) + summaryLines.push(`- uncovered: ${uncovered.length}`) + summaryLines.push(`- processed-through: ${through}`) + appendSummary(summaryLines.join("\n")) + + const prUrl = results.find((r) => r.url)?.url ?? "" + if (prUrl) appendOutput("pr_url", prUrl) + const failures = results.filter((r) => r.error).length console.log( - `PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length}, uncovered=${uncovered.length}, through=${through})`, + `docs-sync: ${results.length - failures} surface PR(s), ${failures} failure(s), files=${changedFiles.length}, uncovered=${uncovered.length}, through=${through}`, ) } diff --git a/.github/docs-sync/watermark.mjs b/.github/docs-sync/watermark.mjs index 32986fef67..eba949c598 100644 --- a/.github/docs-sync/watermark.mjs +++ b/.github/docs-sync/watermark.mjs @@ -24,20 +24,32 @@ function extractMarker(body) { return Number.isNaN(d.getTime()) ? null : d } +/** + * Pick the marker from the first trusted PR in `prs`. The query is sorted by + * `updated-desc`, so that PR is the one the latest run refreshed. Scanning in + * `created-desc` order instead could read a surface PR that this run skipped + * (its body still carries an older marker) and regress or pin the watermark. + */ +export function pickWatermark(prs) { + for (const pr of Array.isArray(prs) ? prs : []) { + // Only trust markers on PRs authored by the bot itself: bodies are + // editable and the label can be applied by anyone with triage access. + if (pr.user?.login !== "github-actions[bot]") continue + const marker = extractMarker(pr.body) + if (marker) return { number: pr.number, marker } + } + return null +} + async function findWatermark() { const r = repo() for (const state of ["open", "merged"]) { - const query = `repo:${r} is:pr label:auto-docs sort:created-desc ${state === "open" ? "is:open" : "is:merged"}` + const query = `repo:${r} is:pr label:auto-docs sort:updated-desc ${state === "open" ? "is:open" : "is:merged"}` const prs = await searchIssues(query, { maxPages: 1 }) - for (const pr of prs) { - // Only trust markers on PRs authored by the bot itself: bodies are - // editable and the label can be applied by anyone with triage access. - if (pr.user?.login !== "github-actions[bot]") continue - const marker = extractMarker(pr.body) - if (marker) { - console.log(`watermark from ${state} PR #${pr.number}: ${marker.toISOString()}`) - return marker - } + const picked = pickWatermark(prs) + if (picked) { + console.log(`watermark from ${state} PR #${picked.number}: ${picked.marker.toISOString()}`) + return picked.marker } } return null diff --git a/.github/workflows/check-opencode-annotations.yml b/.github/workflows/check-opencode-annotations.yml index 60f5501cbf..aa41b12d09 100644 --- a/.github/workflows/check-opencode-annotations.yml +++ b/.github/workflows/check-opencode-annotations.yml @@ -23,8 +23,8 @@ on: jobs: check-annotations: - name: Check kilocode_change / czcode_change annotations - if: github.repository == 'clickzetta/czcode' # czcode_change + name: Check kilocode_change annotations + if: github.repository == 'Kilo-Org/kilocode' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 # kilocode_change @@ -37,14 +37,7 @@ jobs: - name: Check kilocode_change annotations in shared upstream files env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - PR_TITLE: ${{ github.event.pull_request.title }} run: | - # czcode_change start - skip annotation check for upstream merge PRs - if [[ "$PR_TITLE" == merge:* || "$PR_TITLE" == "merge: upstream"* ]]; then - echo "Upstream merge PR detected ('$PR_TITLE') — skipping annotation check." - exit 0 - fi - # czcode_change end if [ -n "$BASE_SHA" ]; then bun run script/check-opencode-annotations.ts --base "$BASE_SHA" else diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml index 1ae78f3edb..6fe23bc00d 100644 --- a/.github/workflows/docs-sync.yml +++ b/.github/workflows/docs-sync.yml @@ -41,8 +41,15 @@ concurrency: cancel-in-progress: false env: - TRIAGE_MODEL: ${{ vars.DOCS_SYNC_TRIAGE_MODEL || 'kilo/moonshotai/kimi-k3' }} - EDIT_MODEL: ${{ vars.DOCS_SYNC_EDIT_MODEL || 'kilo/moonshotai/kimi-k3' }} + TRIAGE_MODEL: ${{ vars.DOCS_SYNC_TRIAGE_MODEL || 'kilo/deepseek/deepseek-v4.1-flash' }} + EDIT_MODEL: ${{ vars.DOCS_SYNC_EDIT_MODEL || 'kilo/deepseek/deepseek-v4.1-flash' }} + # Model id and reasoning effort are both taken from the Kilo gateway model + # list at https://api.kilo.ai/api/openrouter/models (equivalently + # `kilo models kilo`), which serves `deepseek/deepseek-v4.1-flash` with + # `opencode.variants` none/low/high/max; `kilo run --help` documents the + # `--variant` flag that selects one. Overridable, but defaults to `max`: the + # whole point of this bot is hard docs reasoning. + DOCS_SYNC_VARIANT: "max" jobs: selftest: @@ -63,6 +70,9 @@ jobs: - name: Run docs-sync selftest run: node .github/docs-sync/selftest.mjs + - name: Run docs-sync unit tests + run: for f in .github/docs-sync/*.test.mjs; do node "$f"; done + sync: if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request' runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -200,7 +210,7 @@ jobs: # user config granting bash, so without --auto the agent cannot run ordinary # shell commands against the repository. kilo run --auto "The docs build or tests failed. Read the attached docs-sync-out/verify.log and fix the packages/kilo-docs changes so they pass. Do not revert doc edits; fix them. Do not modify anything outside packages/kilo-docs." \ - -m "$EDIT_MODEL" --dir "$GITHUB_WORKSPACE" -f docs-sync-out/verify.log \ + -m "$EDIT_MODEL" --variant "$DOCS_SYNC_VARIANT" --dir "$GITHUB_WORKSPACE" -f docs-sync-out/verify.log \ | node .github/docs-sync/redact-stream.mjs \ | tee -a docs-sync-out/edit-log.txt \ || echo "::warning::kilo fix pass exited nonzero; re-verifying anyway" @@ -232,13 +242,15 @@ jobs: if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true env: GH_TOKEN: ${{ github.token }} + # Read-only token for the Kilo-Org/cloud history; a cloud surface + # falls back to the fixed `other` reviewers when it is unset. + CLOUD_REPO_TOKEN: ${{ secrets.CROSS_REPO_ACCESS_TOKEN }} LEARNED_THROUGH: ${{ steps.learn.outputs.learned_through }} PROCESSED_THROUGH: ${{ steps.wm.outputs.now }} SINCE: ${{ steps.wm.outputs.since }} SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }} BRANCH: ${{ steps.prep.outputs.branch }} PREP_MODE: ${{ steps.prep.outputs.mode }} - PR_NUMBER: ${{ steps.prep.outputs.pr_number }} VERIFIED: ${{ steps.verified.outputs.ok }} run: node .github/docs-sync/upsert-pr.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 01fa5f7f23..ef982ebc2c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -347,7 +347,8 @@ jobs: - name: Install @vscode/vsce run: bun install -g @vscode/vsce - # kilocode_change start - download into /tmp and extract the tar.zst packed by build-cli + # kilocode_change start - download into /tmp and extract the tar.zst packed by build-cli, + # then apply changesets so the packaged VSIX ships the current changelog - uses: actions/download-artifact@v8 with: name: kilo-cli.tar.zst @@ -358,6 +359,12 @@ jobs: run: | mkdir -p packages/opencode/dist tar --zstd -xf /tmp/kilo-cli.tar.zst -C packages/opencode/dist + + - name: Apply changesets to changelog + run: bun script/kilocode/changeset-version.ts + env: + KILO_VERSION: ${{ needs.build-cli.outputs.version }} + GITHUB_TOKEN: ${{ github.token }} # kilocode_change end - name: Build VSIX packages run: bun script/build.ts diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 7e69df2dee..2329dae1eb 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -45,6 +45,9 @@ jobs: env: KILO_API_KEY: ${{ secrets.KILO_API_KEY }} KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }} + # kilo-bench's capture helper requires a Go toolchain >= 1.26. The runner + # image pins GOTOOLCHAIN=local, so allow Go to fetch the required toolchain. + GOTOOLCHAIN: auto steps: - name: Checkout kilo-bench uses: actions/checkout@v6 # kilocode_change @@ -52,6 +55,12 @@ jobs: repository: Kilo-Org/kilo-bench token: ${{ secrets.BENCH_GITHUB_TOKEN }} + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version: "1.26" + cache: false + - name: Install uv uses: astral-sh/setup-uv@v6 with: diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index e4a2df3eb2..375ebd149f 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -4,7 +4,6 @@ on: push: branches: - main - - "**/kilo-opencode-*" # czcode_change: run typecheck on upstream merge branches pull_request: workflow_dispatch: diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index aa0293b7d9..246b973cbe 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -59,7 +59,8 @@ jobs: needs: check-paths if: needs.check-paths.outputs.matched == 'true' name: Visual Regression (kilo-ui) # kilocode_change - runs-on: ${{ github.repository == 'Kilo-Org/kilocode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} # kilocode_change + # kilocode_change: temporary GitHub-hosted runner while Blacksmith apt mirror connectivity is broken, see Blacksmith report for run 34574732611 + runs-on: ubuntu-24.04 # kilocode_change timeout-minutes: 15 steps: @@ -221,7 +222,8 @@ jobs: needs: check-paths if: needs.check-paths.outputs.matched == 'true' name: Visual Regression (kilo-vscode webview) # kilocode_change - runs-on: ${{ github.repository == 'Kilo-Org/kilocode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} # kilocode_change + # kilocode_change: temporary GitHub-hosted runner while Blacksmith apt mirror connectivity is broken, see Blacksmith report for run 34574732611 + runs-on: ubuntu-24.04 # kilocode_change timeout-minutes: 15 env: NODE_OPTIONS: --max-old-space-size=4096 diff --git a/.kilo/plans/agent-manager-multi-project-configuration.md b/.kilo/plans/agent-manager-multi-project-configuration.md deleted file mode 100644 index 340082fbb6..0000000000 --- a/.kilo/plans/agent-manager-multi-project-configuration.md +++ /dev/null @@ -1,262 +0,0 @@ -# Agent Manager multi-project configuration architecture - -**Status:** Blocking architecture for multi-project release - -**Date:** 2026-07-22 - -This document is the canonical configuration specification for Agent Manager multi-project support. The main UI/runtime plan references it and must not duplicate or contradict it. - -The executable sequence from the current branch is [`agent-manager-multi-project-implementation-handoff.md`](./agent-manager-multi-project-implementation-handoff.md). - -## Decision - -Keep the useful current split between user and project settings, but make every Settings read and write target explicit, immutable, revisioned, and independent from Agent Manager activation. - -Multi-project must not ship broadly while Settings can load a draft for project A and resolve its save target from the mutable active project B. - -## Current behavior - -Kilo has four configuration stores: - -| Store | Example | Owner | -|---|---|---| -| VS Code preferences | VS Code `settings.json` | This VS Code user/installation | -| Kilo user config | `~/.config/kilo/kilo.json` | User defaults across projects and Kilo clients | -| Kilo project config | `/.kilo/kilo.jsonc` | Repository behavior and overrides | -| Runtime/session state | In-memory, directory-qualified | One project, worktree, or session | - -The shared Settings save currently calls `splitConfigByScope()`: - -- `commit_message` is written to project config; -- `indexing.enabled` is written to project config; -- all other generic Settings fields are written to user config. - -The Indexing tab also has an explicit Global/Project selector and can write the entire `indexing` object to either layer. This is too broad because project config can receive provider/model/vector-store credentials and infrastructure settings. - -Several controls are VS Code preferences and bypass Kilo config entirely, including autocomplete UI, browser automation, notifications, max auto-approve cost, commit-message output language, and indexing button visibility. - -## Confirmed blocking failure - -The current protocol does not bind a draft to the config target it was loaded from: - -1. `KiloProvider.fetchAndSendConfig()` resolves a mutable current directory and sends unqualified `configLoaded` state. -2. The webview owns one global/project/effective draft. -3. Agent Manager changes the active project from A to B. -4. The webview sends an unqualified `updateConfig`. -5. `KiloProvider.handleUpdateConfig()` resolves the current directory again at save time and may write A's draft to B. - -Reads are directory-scoped, but writes are not bound to the read target. This is the release blocker. - -The backend also lacks an expected target/revision precondition, so external editors or another window can overwrite a config between read and save. - -## Ownership policy - -### VS Code preferences - -These remain in VS Code settings and do not participate in project config: - -- extension language and presentation preferences; -- autocomplete enablement, keybindings, provider, and model; -- browser automation enablement/system Chrome/headless mode; -- notification enablement and sound; -- maximum automatic approval cost; -- commit-message output language; -- indexing button visibility while indexing is disabled; -- multi-project feature enablement. - -### Kilo user config - -These are personal defaults or security policy and should be edited in User scope: - -- default, small, and subagent models and variants; -- provider enablement, custom providers, credentials, and endpoints; -- user/global agents and default agent; -- permission defaults and user tool defaults; -- sandbox policy, network access, writable paths, and allowed hosts; -- compaction, checkpoint/snapshot, and tool-output defaults; -- username and display behavior; -- sharing, remote control, telemetry, and experimental features; -- user/global formatter, LSP, MCP, skills, instructions, commands, and workflows; -- indexing provider, model, credentials, vector storage, and global tuning defaults. - -A trusted project may override many of these at runtime, but editing User scope never writes those overrides. - -### Kilo project config - -These describe repository behavior and are valid project settings: - -- commit-message prompt; -- repository indexing file extensions, include/ignore rules, and deliberate project tuning overrides; -- repository instructions; -- repository skill paths; -- repository commands/workflows; -- trusted project agents; -- trusted project MCP servers; -- repository formatter/LSP overrides; -- repository watcher ignores; -- repository-specific tool restrictions and permission requests. - -Project configuration may override user model/agent/tool defaults, but provider credentials and security-policy weakening must not be silently authored into a repository. - -### Machine-local project consent - -Indexing enablement is privacy consent, not repository configuration. Store it outside the repository, keyed by canonical `ProjectId` in machine-local extension state: - -- newly observed projects default to indexing disabled; -- users explicitly enable indexing for one project on this machine; -- repository config cannot enable indexing; -- repository config may describe what to index, but consent gates whether indexing starts; -- canonical project identity prevents a symlink or alternate path from bypassing consent. - -Effective indexing requires both valid user-global indexing configuration and machine-local consent for that project. - -### Current tabs - -| Settings tab | Correct editable target | -|---|---| -| Models | User by default; explicit Project scope may override models/agents | -| Providers | User only for credentials/endpoints; project-provided entries are source-labelled | -| Agent Behaviour | User or explicit trusted Project scope | -| Auto Approve | User Kilo config; max cost remains VS Code preference | -| Browser | VS Code preferences | -| Checkpoints | User default or explicit Project override | -| Display | User config | -| Autocomplete | VS Code preferences | -| Notifications | VS Code preferences | -| Context | User defaults; repository watcher/instruction rules in explicit Project scope | -| Commit Message | Project scope for prompt; language remains VS Code preference | -| Indexing | User for provider/model/credentials/storage; machine-local project consent for enablement; Project for repository rules | -| Experimental | User config; multi-project also mirrors to VS Code preference | -| Sandboxing | User config only | -| Language | VS Code preference | -| MCP/Commands/Skills | User defaults or explicit trusted Project scope | - -No field silently chooses a file during save. The UI must display its scope. - -## Settings UX - -Use explicit scope and project controls: - -```text -Scope: User | Project - -Project: backend -Target: /projects/backend/.kilo/kilo.jsonc -``` - -- User scope always targets user config. -- Project scope requires an explicit trusted project selector. -- The Settings selector is separate from Agent Manager's active project. -- Opening Settings may initialize the project selector once from the current project, but later Agent Manager switches never change it. -- A dirty project draft cannot move to another project. Selector changes require Save, Discard, or Stay. -- Inherited values show source badges such as User, Project: backend, and Managed. -- Project scope offers Override and Reset to inherited. -- Project-sourced providers cannot be silently deleted from project config through User scope. - -Runtime config still follows the exact session directory. Settings Project scope targets the registered project root, not the active worktree. Worktree-config editing is a separate future feature requiring an explicit `WorktreeRef`. - -## Immutable binding contract - -A Settings read returns an opaque binding: - -```ts -interface SettingsBinding { - id: string - connectionGeneration: number - scope: "global" | "project" - project?: { - projectId: string - root: string - generation: number - } - directory: string - target: { - scope: "global" | "project" - path: string - revision: string - exists: boolean - writable: boolean - } -} -``` - -The write contains only the opaque binding and patch: - -```ts -interface WriteSettingsConfig { - type: "settingsConfig.write" - requestId: string - bindingId: string - set: Record - unset: string[][] -} -``` - -The extension stores the authoritative binding. On write it must: - -1. reject unknown/expired bindings; -2. verify project existence, generation, and trust; -3. capture the binding before the first await; -4. use the binding's stored directory and scope; -5. never call `getWorkspaceDirectory()`, `contexts.active()`, or use a worktree/session fallback; -6. clear a draft only from the matching `{ requestId, bindingId }` response. - -Bindings expire after save, reconnect, trust revocation, project removal, or context generation change. - -## Backend revision contract - -`GET /config/overlay` must return the exact global/project target path, parsed raw target config, effective config/source metadata, and a revision. - -The revision is a SHA-256 fingerprint of canonical target path, existence marker, and exact file bytes. This catches content changes, JSONC comment-only edits, and target changes. - -`PATCH /config/overlay` accepts one scope and requires: - -```ts -{ - scope: "global" | "project" - set: Record - unset: string[][] - expected: { - path: string - revision: string - } -} -``` - -The backend must re-resolve the authoritative target, verify path/revision under a target lock, patch the raw target layer, validate it, atomically replace the file, and return a fresh snapshot. It never accepts an arbitrary client path. - -Expected failures include expired binding, unknown/untrusted project, changed target, revision conflict, invalid config, non-writable target, and I/O failure. Every failure preserves the draft. - -## Required implementation - -1. Add revisioned target descriptors and compare-and-swap writes to the Kilo config overlay API. -2. Split activation-bound runtime config state from binding-keyed Settings editor state. -3. Replace unqualified `configLoaded`/`updateConfig` with settings read/write messages carrying request and binding IDs. -4. Replace hidden `splitConfigByScope` saves with explicit scope on every editable control. -5. Restrict Indexing Project scope to repository rules; keep provider/model/credentials/storage in User scope. -6. Move indexing enablement from project config to machine-local consent keyed by canonical `ProjectId`, default off. -7. Audit direct config mutators outside the save bar, especially provider disconnect, imports/resets, custom providers, work styles, permission rules, and indexing actions. -8. Make Open Project Config take a `ProjectRef`, resolve the immutable registered root, and verify trust. -9. Partition config caches/events by scope, directory, target, revision, and activation generation. - -## Blocking tests - -- Load Settings for A, switch Agent Manager to B, save: only A's bound target changes. -- Same test while selecting A/B worktrees and sessions. -- User-scope save always changes only user config. -- Project-scope save requires the explicit trusted project and changes only its registered root config. -- Dirty drafts survive Agent Manager switches and cannot migrate between Settings projects. -- Out-of-order reads and writes update only the matching request/binding. -- External file change causes a revision conflict without losing the draft. -- A changed config target path causes a target conflict. -- Project removal, generation change, or trust revocation expires its binding. -- Indexing provider/model/credentials/storage never enter project config through the form. -- A repository file containing `indexing.enabled: true` cannot grant indexing consent. -- New projects default to indexing disabled until explicitly enabled on this machine. -- Consent follows canonical project identity across symlink/path aliases and never leaks to another project. -- `commit_message.prompt` and repository indexing rules still support explicit project writes. -- Runtime worktree config uses the worktree directory while Project Settings remains bound to the registered project root. - -## Release gate - -Keep multi-project disabled by default until the immutable binding/revision contract and the blocking tests above are implemented. The useful existing project-local behavior should be preserved, not removed; its write target must become explicit and immutable. diff --git a/.kilo/plans/agent-manager-multi-project-runtime.md b/.kilo/plans/agent-manager-multi-project-runtime.md deleted file mode 100644 index 25bb0d9e07..0000000000 --- a/.kilo/plans/agent-manager-multi-project-runtime.md +++ /dev/null @@ -1,351 +0,0 @@ -# Agent Manager multi-project runtime - -> UI architecture update: the active-body plus background-summary model in this document is superseded by `agent-manager-multi-project-uniform-ui.md`. The runtime findings remain useful, but the final UI renders one permanent real interactive body for every expanded project and requires strict project-qualified routing before background actions are enabled. - -**Status:** Runtime foundation implemented (this worktree); accordion UI pending - -## Implementation status (2026-07-20) - -Landed in this worktree, all covered by unit tests (`tests/unit/agent-project-*.test.ts`): - -- `src/agent-manager/project-paths.ts` — canonical root resolution (`realpath` + `normalizePath`), case-aware `samePath`, deterministic `projectIdFor(root)` (sha1, stable across restarts), `resolveGitRoot`. -- `src/agent-manager/project-registry.ts` — versioned global catalog of **additional** projects (storage-injected, corrupt-safe, dedupe by id, trust/label/order). The pinned workspace project is never persisted. -- `src/agent-manager/project-context.ts` — immutable `ProjectContext` (owns WorktreeStateManager/WorktreeManager/SetupScriptService/stale set per canonical root, lazy creation, peek accessors) and `ProjectContexts` coordinator (pinned derivation, active/expanded lifecycle, trust+flag gating, `syncPinned()` for workspace changes, webview `ProjectSnapshot`s). -- `src/agent-manager/project-messages.ts` — vscode-free handlers: `requestProjects`, `addProject` (folder picker → git root resolve → dedupe), `removeProject`, `selectProject`, `setProjectExpanded`, `trustProject`. All fail closed when the flag is off. -- `src/agent-manager/project-wiring.ts` — factory assembling registry/contexts/deps/host listeners (extracted to keep `AgentManagerProvider.ts` under its 2000-line cap). -- `AgentManagerProvider` — fields `state/worktrees/setupScript/staleWorktreeIds` replaced by context-backed getters; every existing handler now operates on the active project with zero per-handler changes. `onMessage` consumes project messages first and drops messages whose `projectId` mismatches the active project (stale-pane protection). `activateProject` re-runs `initializeState` for the new context; pollers/importer/run/terminal follow the active context through the existing getters. `agentManager.state` now carries `projectId`. -- Protocol — `agentManager.projects` out-message (`multiProject` flag + `ProjectSnapshot[]`); new in-messages listed above; `projectId?` on `agentManager.state` and `createWorktree`. Webview type mirrors updated (`webview-ui/src/types/messages/{extension,webview}-messages.ts`). -- Host — `pickFolder`, `multiProject`, `readProjects`/`writeProjects` (globalState key `agentManager.projects`), `onDidChangeWorkspaceFolders`, `onDidChangeMultiProject`. -- Setting — `kilo-code.new.agentManager.multiProject` (boolean, default false, application scope). -- Changeset — `.changeset/agent-manager-multi-project.md`. - -Behavior with flag off: only the pinned workspace project exists; snapshots contain exactly one project; all registry data is preserved but hidden; no UI change. - -## Remaining for the UI session (accordion) - -- Project accordion rendering from `agentManager.projects`: header-only rows for collapsed/uninitialized projects (registry metadata only), full existing Local/worktree body for the active project, expanded non-active bodies from per-context state (extension must push per-context state with that context's `projectId` — currently `pushState` only pushes the active context). -- Add-project entry point, trust CTA (sends `agentManager.trustProject` then expand/select), remove/rename in project menu. -- `selectProject` before any repo action in a non-active project; send `projectId` on `createWorktree` (extension drops mismatches). -- New Worktree dialog project selector (defaults to active project). -- Extension follow-ups: hidden-cadence polling for expanded projects; session-owner index so Local sessions of project B open/share correctly even while A is active (currently Local sessions resolve through the active root — acceptable only while the UI activates before opening). -- Implemented: per-context `pushState` for expanded non-active projects; per-project git stats/PR pollers (`ProjectPollers` in `project-pollers.ts`) so every expanded accordion receives live stats/PR data tagged with its projectId; webview `project-live.ts` store routing per-project payloads into per-project summary stores; `activate()` keeps the previously active project expanded. -- Known boundary: `KiloProvider.projectID` and session refresh remain single-project; cross-project SSE filtering by session→directory owner is a follow-up (see "Verified findings" below). - -## Objective (original) - -Build the project-aware Agent Manager runtime before changing the UI. The eventual UI is a pinned current-project view plus lazily initialized additional project accordions, with one shared tab/detail area. The first backend slice must be mergeable with the flag disabled and must route today's single project through the same project-aware contracts that secondary projects will use. - -The implementation must preserve these invariants: - -- The first project is always the canonical Git root of the first VS Code workspace folder in the current window. It is derived at runtime, is always first, and cannot be removed or replaced by persisted state. -- Global extension storage contains only the catalog of additional projects and project-list UI metadata. Each repository remains authoritative for its own `.kilo/agent-manager.json`. -- A collapsed, never-opened additional project causes no `.kilo/agent-manager.json` read, Git mutation, setup/run initialization, terminal creation, or poller subscription. -- Every session/worktree/draft operation has a project identity internally. Once secondary-project activation is enabled and more than one project is exposed, missing or mismatched identity is rejected rather than routed to the current workspace. -- The feature flag gates catalog exposure, secondary activation, and multi-project wire/UI behavior. It does not select a separate legacy implementation. - -## Verified findings - -- `AgentManagerProvider` currently owns the panel and all repository-bound resources in one 1,941-line singleton. Its root is always `Host.workspacePath()` and its state/managers are lazily cached against that root (`packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts:56-212`, `1654-1695`). The architecture test caps this file at 2,000 lines and explicitly forbids raising the cap (`packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts:793-854`). -- Opening the panel immediately initializes the current repository, performs `ensureGitExclude`, loads `.kilo/agent-manager.json`, discovers/restores worktrees, registers sessions, starts state-dependent behavior, and recovers prompts (`AgentManagerProvider.ts:267-360`). This whole sequence cannot run for a collapsed catalog entry. -- `WorktreeStateManager` is correctly repository-scoped by constructor root and writes only `/.kilo/agent-manager.json` (`WorktreeStateManager.ts:100-124`, `632-656`, `797-847`). Its save queue and `flush()` are suitable for a context shutdown barrier, but it has no cancellation/dispose primitive (`755-795`). -- The embedded Agent Manager `KiloProvider` is one provider attached to the one panel/webview (`agent-manager/vscode-host.ts:67-169`). It already routes many SDK calls through a session-to-directory map, but local sessions are allowed to fall back to `workspaceFolders[0]` (`KiloProvider.ts:4343-4378`; `kilo-provider-utils.ts:319-345`). That fallback becomes a cross-project write risk. -- `KiloProvider` has singleton project state (`projectID`, `currentSession`, project-scoped caches, one active project directory) and current session refresh treats all extra directories as worktrees of one canonical project (`KiloProvider.ts:344-359`, `1984-2029`; `kilo-provider-utils.ts:230-295`). It is not multi-project-correct without refactoring. -- The shared backend and generated SDK support an explicit `directory` on session share and unshare (`packages/sdk/js/src/v2/gen/sdk.gen.ts:4733-4795`). Sharing must therefore use the same project/session route as prompts and transcript reads. -- Backend session IDs are high-entropy process-global IDs (`packages/opencode/src/id/id.ts:1-17`, `23-33`, `42-49`). Raw IDs can remain the SDK/server identifier, but the UI/runtime identity still needs a project-qualified reference, and route registration must reject an observed cross-project collision. -- `session.status` currently discards the SSE directory before invoking branch naming (`AgentManagerProvider.ts:204-220`). The orchestration bridge subscribes globally and rejects requests outside its root (`orchestration-bridge.ts:175-203`). Creating one unchanged bridge per project would let the wrong bridge reject a valid request before the correct project handles it. -- Multiple `SessionTerminalManager` instances would each register the same global VS Code commands and terminal listeners (`SessionTerminalManager.ts:35-81`). It must remain panel/coordinator-owned, with project-qualified keys and explicit CWDs. Also, `TerminalRouter` currently falls back from an unknown worktree ID to the repository root (`terminal-routing.ts:120-130`), which must be removed in strict routing. -- `VscodeHost.workspacePath()` uses `workspaceFolders[0]` (`vscode-host.ts:172-174`; `review-utils.ts:12-15`). It loses URI scheme/authority, and `openFolder` recreates paths with `Uri.file`. A catalog containing only path strings would be unsafe across local, SSH, container, and other remote extension hosts. -- Existing Kilo experimental config is CLI/project config, not an application feature-flag service (`features.ts`; `webview-ui/src/types/messages/config.ts`). A multi-project bootstrap flag cannot be project-scoped because choosing which project config to read is the behavior being gated. - -## Architecture decisions - -### 1. Project identity and registry semantics - -Add a VS Code-free `ProjectRegistry` backed by an injected storage and resolver interface. `vscode-host.ts` implements those interfaces using `ExtensionContext.globalState`, workspace/open-dialog URIs, filesystem canonicalization, and Git. - -Use these core types in `src/agent-manager/project.ts`: - -```ts -type ProjectId = string - -interface ProjectLocation { - uri: string - scheme: string - authority: string - root: string - commonGitDir: string -} - -interface ProjectDescriptor extends ProjectLocation { - id: ProjectId - name: string - pinned: boolean - collapsed: boolean - order: number - status: "unverified" | "ready" | "missing" | "notGit" | "wrongAuthority" -} -``` - -`root` is the canonical filesystem Git toplevel. Resolve it only when deriving the pinned project, adding a project, or expanding an unverified project: - -1. Start from the selected workspace/folder URI. -2. Reject a URI whose scheme/authority is not the current extension-host scope. -3. Run `git -C rev-parse --show-toplevel` through the existing hidden-window process wrapper. -4. Resolve the result with `fs.promises.realpath`; normalize separators, trailing separators, and Windows case for identity comparison. -5. Resolve and canonicalize `git rev-parse --path-format=absolute --git-common-dir` as `commonGitDir`. -6. Derive `ProjectId` deterministically as a versioned SHA-256/base64url hash of `scheme + NUL + authority + NUL + normalized canonical root`. Do not expose a path-concatenated ID on the wire. - -Different linked worktree roots have different project IDs, but the first iteration must reject adding a project whose canonical `commonGitDir` matches any project already exposed in the window. Two Agent Manager contexts mutating the same Git common directory would bypass the current root-keyed worktree lock and could manage the same branches twice. This is a product-valid restriction to distinct Git projects, not a path workaround. Also change `WorktreeManager`'s write-lock key to canonical `commonGitDir` so future relaxation does not reintroduce `index.lock` races. - -Persist one unsynced global-state value under `kilo.agentManager.projects.v1`: - -```ts -interface StoredProjectCatalogV1 { - version: 1 - projects: Array<{ - id: ProjectId - uri: string - scheme: string - authority: string - root: string - commonGitDir: string - name?: string - collapsed: boolean - order: number - addedAt: string - }> -} -``` - -Do not call `setKeysForSync`; machine/remote paths must not sync to other machines. Serialize mutations in-process. Each mutation re-reads the latest value, validates it, applies add/remove/reorder/metadata changes, and performs one `globalState.update`. - -Registry behavior is exact: - -- `snapshot(flag)` derives the pinned current project fresh. It never reads a persisted active project. -- The pinned descriptor is first, has `pinned: true`, and defaults to expanded. It is not written into the catalog. -- Persisted entries matching the pinned project ID are suppressed, not deleted. They reappear as additional projects when another window has a different pinned project. -- Additional entries are filtered to the current URI scheme/authority before exposure. Entries for other authorities remain untouched in storage. -- Reading the catalog does not stat, realpath, run Git, or open per-repository state. Persisted additional entries start as `unverified`. -- Adding validates/canonicalizes the chosen folder, rejects the pinned project, duplicate IDs, duplicate common Git directories, and authority mismatches, then persists it. A newly added project may be marked expanded and explicitly activated by the caller; listing alone never initializes it. -- A missing or no-longer-Git root is marked unavailable only when expansion validates it. Keep the entry and its metadata. Do not prune it automatically and do not create a `ProjectContext`. -- Removing is allowed only for non-pinned projects. It removes only the catalog entry after the context is idle/disposed; it never deletes `.kilo`, worktrees, sessions, branches, or shares. -- On every extension/panel restart, the active selection is the pinned project's Local context. Persisted additional projects and collapse/order/name metadata survive, but a stale secondary active session does not override the current VS Code window. - -For a multi-root VS Code workspace, preserve current behavior by deriving the pinned project from `workspaceFolders[0]`, then canonicalize that folder to its Git toplevel. Do not silently select another workspace folder if the first is not a Git repository. - -### 2. Coordinator and immutable lazy ProjectContext - -Turn `AgentManagerProvider` into the panel-level coordinator and extract repository behavior into `project-context.ts`. Do not instantiate one current `AgentManagerProvider` per project. - -The coordinator owns: - -- the one panel and embedded `KiloProvider`/`SessionProvider`; -- `ProjectRegistry`, active `ProjectId`, exposed descriptors, and `Map`; -- a shared `Semaphore(3)` for Git/network polling across all projects; -- the one project/session router; -- one project-aware `AgentManagerVisiblePresence`; -- one `SessionTerminalManager` and one project-aware `TerminalRouter`; -- one global orchestration ingress and the global SSE/tool/status subscriptions; -- project and activation generations used to discard stale async results. - -Each `ProjectContext` is constructed once with an immutable descriptor/root/common Git directory and owns only repository-bound resources: - -- `WorktreeStateManager`, `WorktreeManager`, `SetupScriptService`, and repository `GitOps`; -- local diff functions, `WorktreeDiffController`, `WorktreeImporter`, and `RunController`; -- `GitStatsPoller`, `PRStatusBridge`/poller, and `BranchNamingController`; -- per-project cached state/stats/stale IDs, managed/open session refs, and initialization promise; -- project-specific message handlers currently embedded in `AgentManagerProvider`. - -It does not own a webview, `KiloProvider`, VS Code terminal command registrations, global connection subscriptions, or a global orchestration subscription. - -Lifecycle: - -1. `cold`: descriptor only. No state manager, manager, poller, watcher, or per-repo read exists. -2. `initializing`: one shared promise validates the location, constructs resources, performs the existing `ensureGitExclude -> state.load -> recoverWorktrees -> register routes` sequence, and captures a context generation. -3. `ready/expanded`: posts project-stamped state and permits project actions. Pollers run only when both the panel is visible and this project is expanded. Multiple expanded contexts may be ready concurrently. -4. `suspended/collapsed`: if active, the coordinator first activates pinned Local (or another explicit target). Stop stats/PR/diff polling and watchers, stop accepting new project mutations, wait for the current mutation barrier, flush state, and detach project-specific event side effects such as automatic branch naming. Keep validated session routes/tracking so a running backend session can still surface status and permission/question events without a repository read. Do not abort agents merely because an accordion collapsed. -5. `disposed`: increment generation first, stop/dispose resources, await terminal-router/run/mutation cleanup and state flush, detach routes, then remove the exact context object from the map. Late completions may neither post nor mutate a replacement context. - -Expansion of a suspended context resumes its existing in-memory state and pollers; expansion of a cold context initializes. Concurrent expansions share the same promise. Collapse during initialization records the desired collapsed state, allows non-cancellable Git/state work to finish, suppresses stale posts, then immediately suspends. - -The coordinator routes global events by canonical event directory before invoking a context. `session.status` must retain its directory. A single orchestration ingress determines the one owning context and delegates there; it rejects an unknown directory once, globally. It must never create one rejecting bridge per project. A collapsed project does not auto-initialize in response to an unsolicited orchestration event; return a typed `project_inactive` result. - -Panel close keeps existing semantics for sessions actually opened/created by that panel, but aggregates their project-qualified routes before aborting. Project collapse and flag-off transitions do not abort them. Removing a project is refused while one of its tracked sessions or Agent Manager run/mutation is busy, rather than orphaning an interaction prompt. - -### 3. One embedded KiloProvider, made project-aware - -Use one embedded `KiloProvider`. Multiple providers are not safe or useful here: - -- there is one webview and one message handler; -- each provider would own conflicting current-session/cached UI state and duplicate SSE handling; -- multiple terminal/visibility registrations would be ambiguous; -- it would not solve session identity at the shared webview boundary. - -Refactor its Agent Manager adapter around an injected project/session route service while leaving sidebar and standalone providers unchanged. The route service supplies: - -```ts -interface ProjectSessionRef { - projectId: ProjectId - sessionId: string -} - -interface SessionRoute extends ProjectSessionRef { - directory: string - generation: number -} -``` - -Internally key UI maps by `projectId + NUL + sessionId`; never use that concatenation on the wire. Keep the raw `sessionId` for SDK calls and backend presence. Maintain a reverse raw-ID index only to process backend events. If the same raw ID is observed in two projects, mark it ambiguous and reject/drop operations and events that cannot also be disambiguated by event directory. - -Required routing changes: - -- Register an explicit route for every Agent Manager session, including Local sessions at a project root. Do not omit a map entry merely because it equals the first workspace root. -- New/draft operations resolve from the outer project envelope and an explicit Local/worktree context. Unknown worktree IDs are errors; remove `TerminalRouter`'s fallback to root. -- Existing session operations resolve only from `ProjectSessionRef`. In strict multi-project mode, an unknown session never falls back to `workspaceFolders[0]` or the currently active project. -- Child/subagent sessions inherit the parent's project and directory. A directory-bearing event must also pass the owning-context check before registration. -- Session list refresh runs per project root plus that project's worktree directories. It emits sessions stamped with that project; it does not infer one canonical `projectID` from the first root and merge all projects into it. -- Replace singleton Agent Manager use of `KiloProvider.projectID` with the route table/directory ownership check. Keep existing project filtering for non-Agent-Manager provider instances. -- On active-project/session change, call one `activateProject(projectId, root)` path. It updates `projectDirectory`, invalidates/refetches project-scoped config, providers, agents, commands, skills, MCP, indexing, sandbox, and Git status for that root. Guard every async refresh with activation generation so project A cannot populate caches after switching to B. -- Every session create, continuation, prompt, permission/question response, terminal operation, diff operation, file/context operation, and share/unshare SDK call receives the resolved explicit directory. - -This picks up each secondary project's config naturally through the CLI's directory-scoped instance/config resolution. Do not copy provider/config/settings data into global storage. - -### 4. Project-qualified protocol and share semantics - -Define an internal/wire envelope in `agent-manager/types.ts` and mirror it in `webview-ui/src/types/messages/agent-manager.ts`: - -```ts -interface ProjectStamp { - projectId: ProjectId - generation: number -} - -interface ProjectEnvelope { - type: "agentManager.projectMessage" - project: ProjectStamp - payload: T -} -``` - -Use project-qualified references for sessions, worktrees, and drafts: - -```ts -interface SessionRef { projectId: ProjectId; sessionId: string } -interface WorktreeRef { projectId: ProjectId; worktreeId: string } -interface DraftRef { projectId: ProjectId; draftId: string } -``` - -Raw IDs remain in each repository's existing state file. The repository/context supplies the project identity when state is loaded. Do not migrate session IDs into global state and do not permit moving an existing session record between projects. A future cross-project continuation must explicitly fork/create in the target project; relabeling a raw session is invalid. - -Protocol rules: - -- The coordinator is the only component that unwraps inbound envelopes and wraps context output. -- Global catalog/route errors are global messages. Repository state, stats, session metadata, diff, run, PR, terminal, and chat messages are project envelopes. -- Preserve `requestId` inside the payload. Return a typed route error (`project_required`, `project_unknown`, `project_inactive`, `project_mismatch`, `session_unknown`, or `session_ambiguous`) without forwarding the original message. -- Validate the envelope project against the context, persisted session/worktree ownership, session route, and event/request directory. A valid raw ID in the wrong envelope is still rejected. -- Outbound generation lets the webview/coordinator discard messages from a collapsed/disposed/replaced context. -- While the flag is off, or while only the pinned project is exposed, a compatibility adapter wraps today's unqualified webview messages with the pinned project and unwraps pinned output to the existing shape. The core handlers still receive envelopes. -- Once the flag is on and a second project is exposed, unqualified project/session/worktree messages fail closed. The multi-project UI must send/consume envelopes and use composite refs as tab/store keys. - -Sharing is an operation on `SessionRef`, not on a bare session ID: - -- Manual share calls `client.session.share({ sessionID, directory })`; unshare uses the exact same resolved route. -- Automatic sharing remains a project config behavior because session creation is sent with the target project's directory. -- Store no share URL or share state in the project catalog. Session list/get/update data remains authoritative and is enriched with `projectId` on output. -- Removing/collapsing a project never unshares its sessions. A share URL is externally usable, but reopening or mutating the local session still requires its local project route. -- A mismatched or missing project cannot share/unshare, even if the raw session ID happens to resolve elsewhere. - -### 5. Feature flag, restart, and migration behavior - -Contribute an application-scoped VS Code setting, default false, for example `kilo-code.new.agentManager.multiProject`, with `scope: "application"`. Read it in the host/coordinator; do not put it in CLI `experimental` config. - -- Flag off: derive and initialize only pinned current project, use the compatibility wire adapter, and preserve the exact current view. Read but do not expose, validate, initialize, rewrite, or delete additional catalog entries. -- Flag on: publish lightweight descriptors. Only explicit expansion initializes an additional project. Selecting Local/worktree also expands and initializes its owner before activation. -- Runtime on -> off: increment activation generation, activate pinned Local, suspend all secondary contexts, switch to compatibility protocol, and preserve catalog/per-repo state. Do not abort backend sessions. -- Runtime off -> on: expose stored descriptors as cold/unverified. Do not eagerly restore a persisted secondary active project. - -No `.kilo/agent-manager.json` schema migration is needed. Existing state is loaded unchanged by the pinned context, and additional contexts load their own existing files only when expanded. An absent catalog means no additional projects. Invalid catalog entries are ignored in the snapshot and logged, but a successful write must preserve valid entries from other authorities. Never scan the filesystem to discover projects as migration. - -On restored legacy panel/webview state, unqualified tabs are accepted only in compatibility single-project mode. When multi-project mode exposes a second project, send a protocol reset and rebuild tabs/session lists from project envelopes instead of guessing which project owns a restored raw session ID. - -Remote behavior: - -- Scope catalog exposure and identity by full URI scheme + authority, not `vscode.env.remoteName` alone. -- Run Git and filesystem operations in the current extension host against `fsPath`; preserve the original URI for `openFolder` and picker operations. -- Reject selection from another authority. Keep such stored entries for their matching window/host. -- Unsupported virtual filesystems, missing roots, permission failures, and non-Git directories become unavailable descriptors after explicit validation. They do not fall back to the pinned root. - -## Ordered implementation plan - -### Slice A: mergeable routing foundation before multi-project UI - -This is the narrow first PR. It produces no secondary-project UI, but the current pinned project runs through the durable identity/routing contracts and the registry is fully tested. - -1. Add `src/agent-manager/project.ts` with IDs, locations, descriptors, stamps, envelopes, and ref types. Add `src/agent-manager/project-registry.ts` with injected storage/resolver, v1 schema validation, pinned merge, authority filtering, dedupe, and serialized mutations. -2. Extend `Host` in `src/agent-manager/host.ts`; implement global-state storage, canonical Git/URI resolution, pinned project resolution, and the application flag in `src/agent-manager/vscode-host.ts`. Preserve URIs in `openFolder`. Add the setting contribution in `packages/kilo-vscode/package.json`. -3. Add `src/agent-manager/project-session-router.ts`. It must register Local and worktree routes, resolve refs/directories, inherit child routes, validate directory ownership, detect raw-ID ambiguity, and implement compatibility versus strict mode. -4. Add envelope/route errors to `src/agent-manager/types.ts` and the mirrored webview type file. Add a coordinator adapter in `AgentManagerProvider` that derives the pinned project at panel attach, wraps all legacy inbound messages, stamps context output, and switches strictness based on exposed-project count. In this slice only the pinned context is activated, so rendered behavior remains unchanged. -5. Extend the `SessionProvider` adapter (`host.ts`, `vscode-host.ts`) with project-aware `setSessionRoute`, `trackSession`, `getSessionInfo`, `loadMessages`, `share`, and `unshare` methods. Retain temporary legacy methods only as single-project adapters, not as a second implementation. -6. Modify `KiloProvider`, `kilo-provider/options.ts`, and the extracted helpers in `kilo-provider-utils.ts` so Agent Manager Local sessions also have explicit routes, unknown strict routes fail closed, child routes inherit project identity, and project activation invalidates project-scoped cache work by generation. Keep non-Agent-Manager provider behavior unchanged. -7. Update `extension.ts` auto-approve/session-directory lookup to ask the router/coordinator for the active or unique `SessionRef`; do not merge raw ID maps and choose the first match. -8. Remove unknown-worktree-to-root fallback in `terminal-routing.ts`, and inject the pinned project root into `createTerminalHost` instead of reading `workspaceFolders[0]` internally. - -Slice A is complete when the flag-disabled Agent Manager is visually and behaviorally unchanged, every current project session has an explicit project route, and unit tests prove a second registered route cannot be accidentally handled through pinned-root fallback. Do not expose Add Project until Slice B is complete. - -### Slice B: lazy multi-context runtime, still before UI rendering - -1. Add `src/agent-manager/project-context.ts` and move root-bound state/managers/handlers out of `AgentManagerProvider`. Keep context code VS Code-free. Reduce the provider cap to the new rounded-up line count instead of raising it. -2. Make `AgentManagerProvider` the coordinator described above. Add cold/initializing/ready/suspended/disposed state, generation guards, expansion/activation APIs, shared semaphore, and per-project message dispatch. -3. Refactor status/tool/orchestration subscriptions so the coordinator routes once by project/directory. Do not retain per-context global subscriptions that can reject each other's requests. -4. Make terminal management panel-global and key mappings by composite session/worktree refs. A context supplies only an already-validated CWD. Ensure context collapse cannot switch to another project's terminal by raw ID. -5. Refactor session refresh to accept one project at a time and emit project-stamped results. Register routes for discovered Local sessions before they can be opened. Preserve failed-directory session IDs only within the affected project. -6. Change `WorktreeManager`'s mutation lock key to canonical common Git directory and reject adding another exposed context with the same common directory. -7. Add backend catalog commands (`requestProjects`, `addProject`, `removeProject`, `expandProject`, `collapseProject`, `activateProject`) behind the flag. These are testable protocol endpoints but need not yet have visible controls. - -### Slice C: first usable flagged UI - -After A and B are green, implement project accordions and the project selector in the New Worktree modal. Keep the current markup/layout when only the pinned project exists. Use composite refs in webview stores/tabs, one shared tab bar/detail pane, and clearly show the project name on cross-project session tabs and share actions. This UI slice is intentionally outside the backend-first scope. - -## Exact tests - -Add or update these focused tests under `packages/kilo-vscode/tests/unit/`: - -- `agent-manager-project-registry.test.ts`: pinned project is always first/fresh; persisted active cannot replace it; additional roots survive restart; pinned duplicate is suppressed; add canonicalizes subdirectories/symlinks; duplicate root/common Git dir and wrong authority are rejected; flag-off snapshot exposes only pinned without mutating catalog; catalog listing performs no resolver/filesystem calls; missing entries remain stored. -- `agent-manager-project-router.test.ts`: every Local/worktree session has a route; matching refs resolve; wrong project, unknown session, unknown worktree, missing project, and raw-ID collision fail closed; child inherits parent project/directory; compatibility inference works only for one exposed project; share and unshare use the same explicit directory. -- `agent-manager-project-context.test.ts`: collapsed cold projects construct/read nothing; concurrent expand initializes once; two expanded contexts use separate state roots and share the concurrency gate; collapse stops pollers and flushes; collapse during init suppresses posts; disposed-generation results cannot update a replacement context; remove is blocked while busy. -- Extend `kilo-provider-session-refresh.test.ts`: lists per project instead of merging project roots; outputs project stamps; failed worktree listing preserves only that project's sessions; activation generation drops stale config/session results. -- Extend `kilo-provider-worktree-context.test.ts`: strict unknown session/worktree has no workspace fallback; Local routes explicitly resolve to their own project root; a secondary Local session cannot resolve to pinned root. -- Extend `AgentManagerProvider.spec.ts`: single-project legacy messages are wrapped pinned; two-project unqualified messages return `project_required`; mismatched session/project returns `project_mismatch`; status/tool events dispatch by directory; restart selects pinned Local. -- Add terminal routing coverage: unknown project/worktree produces an error and no PTY/VS Code terminal; identical raw session IDs in different projects cannot select the wrong terminal. -- Extend `agent-manager-arch.test.ts`: new domain modules do not import `vscode`; lower `AgentManagerProvider.ts` maxLines after extraction. - -Run from `packages/kilo-vscode/` after each slice: - -```sh -bun test tests/unit/agent-manager-project-registry.test.ts tests/unit/agent-manager-project-router.test.ts tests/unit/kilo-provider-session-refresh.test.ts tests/unit/kilo-provider-worktree-context.test.ts -bun run typecheck -bun run lint -bun run test:unit -bun run knip -bun run check-kilocode-change -``` - -For Slice B, add the context/provider/terminal tests to the focused first command. No CLI/SDK regeneration is required because this architecture uses existing explicit-directory SDK parameters and adds only extension/webview protocol types. - -## Manual validation after the UI slice - -1. With the flag off and a non-empty stored catalog, restart VS Code. Agent Manager must look and behave exactly as today and use only the current window's Git root. -2. Turn the flag on, add a second repository, restart, and verify its header persists while the pinned current project remains first and active. Confirm the collapsed second project creates no output/state access/poll traffic until expanded. -3. Expand both projects, open Local and worktree sessions from each, switch rapidly, send prompts, answer permission/question requests, open terminals/diffs, and share then unshare. Verify every operation affects the displayed project's repository and config. -4. Disable the flag while a secondary project is active. Verify pinned Local becomes active, secondary state remains on disk/catalog, and re-enabling restores the descriptor without eager initialization. -5. Rename/remove a cataloged directory and restart. Verify the project remains as unavailable, cannot route actions to pinned root, and can be removed without touching its repository state. -6. Repeat in a remote window. Verify local/other-authority catalog entries are hidden but preserved and folder opening retains the remote URI authority. - -## Risks and rollback - -- The largest correctness risk is a hidden workspace-root fallback in `KiloProvider` or a helper. Treat every Agent Manager method that accepts `sessionID`, `worktreeId`, `draftID`, `requestID`, or directory as part of the routing audit. Tests should fail if strict mode calls `workspaceFolders[0]` for an identified operation. -- Project-scoped KiloProvider caches can show stale settings after a rapid project switch. Activation generation checks are required before posting and before replacing caches; merely cancelling transcript load is insufficient. -- Global event consumers can race. There must be exactly one ownership decision for orchestration/tool events, and directory must be retained for status/permission/question handling. -- Multiple contexts increase polling/process pressure. Share the concurrency semaphore, gate all pollers by panel visibility and expansion, and suspend on collapse/flag-off. -- Global-state updates from separate VS Code windows do not provide a transactional CAS. The first iteration should re-read before each serialized mutation and tolerate another window appearing after reload; do not build a filesystem lock or custom database for this feature. -- Rollback is safe: turn off the application flag. The pinned path and existing `.kilo/agent-manager.json` format remain intact, while the unused global catalog is preserved for a later re-enable. diff --git a/.kilo/plans/agent-manager-multi-project-uniform-ui.md b/.kilo/plans/agent-manager-multi-project-uniform-ui.md deleted file mode 100644 index 5beb0e3ef2..0000000000 --- a/.kilo/plans/agent-manager-multi-project-uniform-ui.md +++ /dev/null @@ -1,740 +0,0 @@ -# Agent Manager multi-project uniform UI architecture - -**Status:** Ready, corrected architecture; implementation pending - -**Date:** 2026-07-22 - -This plan supersedes the active-body plus background-summary UI described in `agent-manager-multi-project-runtime.md`. The existing project registry, immutable project-root concept, per-project state payloads, and project-tagged stats work are useful foundations. The current read-only `ProjectSummary` and active-project dynamic getter routing are not the target architecture. - -Configuration reads, writes, scopes, drafts, trust, revisions, and release-blocking tests are specified canonically in [`agent-manager-multi-project-configuration.md`](./agent-manager-multi-project-configuration.md). If this plan and that document differ on configuration behavior, the configuration document wins. - -The step-by-step implementation sequence from the current branch is [`agent-manager-multi-project-implementation-handoff.md`](./agent-manager-multi-project-implementation-handoff.md). Implementers should follow that handoff slice by slice rather than treating this architecture document as an unordered task list. - -## Product requirement - -Every expanded project permanently renders the real existing Agent Manager sidebar UI: - -- the real Local card; -- the real worktree cards, including their current menus and actions; -- the real session list, which may initially use a simplified layout but must contain real live session data; -- live git stats, PR state, run/setup state, stale state, and session state owned by that project. - -Selecting or interacting with a project may change the shared detail pane and top toolbar. It must not remove, replace, downgrade, or remount any expanded project body. There is no active-body versus background-summary rendering mode. - -The shared detail pane remains singular. The sidebar displays all expanded projects concurrently; the detail pane displays one selected project/local/worktree/session target at a time. - -## Architecture verdict on the current worktree - -The current implementation is a valid partial runtime foundation, but it is not safe to extend directly into fully interactive background cards. - -Keep and evolve: - -- `ProjectRegistry` and the pinned-workspace project concept; -- immutable root ownership in `ProjectContext`; -- `initContextState` extraction; -- project-stamped state/stats/PR messages; -- per-project webview stores; -- reusable `WorktreeItem`, `SectionHeader`, `WorktreeSectionActions`, and `UnassignedSessionsSection` components; -- the application-scoped feature flag. - -Replace or refactor before exposing interactive multi-project UI: - -- delete `ProjectSummary`; do not add more behavior to it; -- remove the rule that only `project.active` renders `renderBody()`; -- stop routing normal actions through `contexts.active()` and optional `projectId`; -- replace raw session/worktree/local UI keys with project-qualified references; -- replace the active singleton poller plus background poller split with uniform context-owned polling; -- make context initialization, mutation, suspension, and disposal explicit and generation-guarded; -- make the one embedded `KiloProvider` project/session-route aware. -- separate activation-bound runtime config from the Settings editor's immutable read/write binding. - -## Core model - -### Project identity - -A project is one canonical Git repository root in the current extension host. - -```ts -type ProjectId = string - -interface ProjectRef { - projectId: ProjectId -} - -interface WorktreeRef extends ProjectRef { - worktreeId: string -} - -interface SessionRef extends ProjectRef { - sessionId: string -} - -type SidebarTarget = - | { projectId: ProjectId; kind: "local" } - | { projectId: ProjectId; kind: "worktree"; worktreeId: string } - | { projectId: ProjectId; kind: "session"; sessionId: string } -``` - -Raw backend and repository-local IDs remain unchanged. Composite references are required at every panel/runtime boundary so identical local sentinels, worktree IDs, section IDs, or session IDs cannot cross projects. - -Project identity must include the extension-host URI scheme and authority in addition to the canonical root. Adding a project must also resolve canonical `git-common-dir` and reject another exposed project sharing that common Git directory. Linked worktrees of one repository cannot be registered as independent projects in the first release. - -### Active project means detail selection only - -`activeProjectId` is not a rendering mode and not an implicit mutation target. - -It means: - -- which project owns the current shared detail/chat/diff/terminal pane; -- which project the global top toolbar and global New Session/Run buttons target; -- which project/worktree directory supplies runtime-effective Kilo configuration in the one embedded `KiloProvider`. - -It never selects a Settings write target. Settings bindings are explicit and remain unchanged when detail selection changes. - -Project expansion controls rendering. Every `project.expanded` project renders one stable `ProjectSidebarBody` keyed by `projectId`, regardless of active status. - -### Atomic selection - -Do not send `selectProject` followed by an unqualified worktree/session action. VS Code message handlers are asynchronous and are not a transaction queue. - -Use one atomic selection intent: - -```ts -interface ActivateSelectionMessage { - type: "agentManager.activateSelection" - target: SidebarTarget -} -``` - -The coordinator validates the project and target, ensures the context is ready, activates the project-specific Kilo route, then emits one project-qualified activation result. The sidebar body stays mounted; only shared detail state changes. - -Mutations that do not need the detail pane, such as rename, delete, section edits, run, setup, open window, PR actions, or worktree creation, execute directly against their explicit `ProjectRef` or `WorktreeRef`. They do not activate another project as a side effect. - -## Configuration architecture - -See [`agent-manager-multi-project-configuration.md`](./agent-manager-multi-project-configuration.md) for the complete contract. - -### Verified blocking failure - -The current config protocol is unsafe for multi-project use: - -- `KiloProvider.fetchAndSendConfig()` resolves a mutable current directory and emits one unqualified `configLoaded` value (`packages/kilo-vscode/src/KiloProvider.ts:2513`). -- `ConfigProvider` owns one global/project/effective draft and sends an unqualified `updateConfig` (`packages/kilo-vscode/webview-ui/src/context/config.tsx:54`, `:243`). -- `KiloProvider.handleUpdateConfig()` resolves `getWorkspaceDirectory()` again at save time (`packages/kilo-vscode/src/KiloProvider.ts:2982`). A draft loaded for project A can therefore be written to project B after detail activation changes. -- The backend derives the project target from request directory and has no target or revision precondition (`packages/opencode/src/config/config.ts:944`, `packages/opencode/src/kilocode/config/config.ts:67`). Concurrent editors can overwrite one another, and a newly created higher-priority config file can redirect a pending write. -- Hidden scope splitting currently writes `commit_message` and `indexing.enabled` to the project while most fields go global (`packages/kilo-vscode/webview-ui/src/utils/config-scope.ts:3`). The UI does not make this target choice explicit. -- Project config writes also exist outside the save bar. In particular, provider disconnect can call `saveProject()` using the mutable provider workspace directory (`packages/kilo-vscode/src/provider-actions.ts:252`). Disabling only `handleUpdateConfig()` is insufficient. - -This is a release blocker, not a follow-up cleanup. - -### Product decision and staged order - -Preserve useful project-local editing such as `commit_message.prompt` and repository indexing rules. Indexing enablement is machine-local project consent, default off, and cannot be granted by repository config. Replace hidden active-directory targeting with explicit `User | Project` scope, a required Settings project selector for Project scope, an opaque immutable binding, and optimistic concurrency by target revision. - -Do not maintain one write model for flag-off mode and another for multi-project mode. Single-project mode uses the same explicit protocol, with a narrow adapter that injects the pinned `ProjectRef` for reads and open-file actions. - -### Separate runtime config from Settings editor config - -The current `ConfigProvider` conflates two different state machines. Split them: - -- **Runtime config** is read-only UI state for the selected detail/session route. It is keyed by `{ projectId, directory, activationGeneration }`, follows atomic detail activation, and drives chat, providers, agents, features, display, MCP, indexing, and sandbox behavior. -- **Settings editor config** is a scoped layer editor keyed by an immutable binding. It never follows detail activation and its draft is never applied optimistically to runtime config. - -Use distinct messages. Names may follow repository conventions, but the fields and invariants are mandatory: - -```ts -interface RuntimeConfigLoaded { - type: "runtimeConfigLoaded" - route: { - projectId: ProjectId - directory: string - activationGeneration: number - } - config: Config - features: FeatureFlags -} - -type ConfigScope = "global" | "project" - -interface ConfigTarget { - scope: ConfigScope - path: string - revision: string - exists: boolean - writable: boolean -} - -interface SettingsBinding { - id: string - connectionGeneration: number - scope: ConfigScope - project?: { - projectId: ProjectId - root: string - generation: number - } - directory: string - target: ConfigTarget -} - -interface ReadSettingsConfig { - type: "settingsConfig.read" - requestId: string - scope: ConfigScope - projectId?: ProjectId -} - -interface SettingsConfigSnapshot { - type: "settingsConfig.snapshot" - requestId: string - binding: SettingsBinding - preview?: { - projectId: ProjectId - root: string - generation: number - directory: string - } - targetConfig: Config - effective: Config - global: Config - project: Config - fields: Record - collections: Record -} - -interface WriteSettingsConfig { - type: "settingsConfig.write" - requestId: string - bindingId: string - set: Partial - unset: string[][] -} -``` - -For a global read, `projectId` is optional preview context used only to explain project shadowing. It never changes the global target. A project read requires `projectId` and always resolves the immutable `ProjectContext.root`, not the active worktree or session. - -For User scope, preview identity is presentation state, not part of the editable target identity. The controller keeps the global target binding and draft separate from the latest project preview. A clean preview change may replace both from one fresh snapshot. A dirty preview change updates only `preview`, effective/source metadata, and project raw data. It retains the original global target revision; if the fresh read reports a different global path or revision, mark the draft stale instead of silently rebasing it. - -`targetConfig` is the parsed raw content of the one file the API will patch. It is not the aggregate `global` or `project` layer. The form edits this raw target value so it never copies values inherited from another file into the target. The aggregate values remain in the snapshot solely for source, inheritance, and effective-preview UI. Existing JSONC comments remain server-side raw text and are preserved by patching; the webview does not round-trip serialized file contents. - -Parse `targetConfig` from raw JSONC without expanding `{env:}` or `{file:}` variables, so the Settings protocol never turns placeholders into secret values. If the exact target is syntactically or structurally invalid, return diagnostics and `writable: false` for form editing rather than treating it as `{}` and overwriting it. The recovery action is **Open file**. Source metadata must cover every path rendered by Settings, not only the current hand-maintained overlay field list. - -The extension owns an in-memory binding registry. Binding IDs are opaque, unpredictable, and scoped to one provider/webview lifecycle. The webview returns only `bindingId`, not a client-authoritative path or project envelope. On write the extension must: - -1. look up `bindingId` and reject unknown or expired bindings; -2. verify that the project still exists, has the same context generation, and remains trusted; -3. capture the stored binding before the first `await`; -4. send the stored directory and expected target to the backend without calling `getWorkspaceDirectory()`, `contexts.active()`, or any fallback; -5. route success or failure by `{ requestId, binding.id }` only. - -A binding expires after a successful save, backend reconnect, trust revocation, project removal, or context generation change. Success returns a new snapshot and binding. Cached snapshots are keyed by binding/scope/project identity; there is no singleton `cachedConfigMessage` for Settings. - -### Backend read/write and optimistic concurrency contract - -Evolve the Kilo-owned `/config/overlay` API rather than adding project identity to generic `Config.update`. The backend does not know Agent Manager `projectId`; it enforces the routed directory and filesystem target while the extension enforces project identity. - -`GET /config/overlay` continues to take explicit `directory` and `scope`, but returns: - -```ts -interface ConfigOverlaySnapshot { - context: { directory: string; worktree?: string } - scope: ConfigScope - targetConfig: Config - effective: Config - global: Config - project: Config - sources: ConfigSource[] - targets: { - global: ConfigTarget - project: ConfigTarget - active: ConfigTarget - } - fields: Record - collections: Record -} -``` - -The revision is a SHA-256 fingerprint of scope, canonical target path, existence marker, and exact file bytes. It is not an mtime. Exact bytes are required so comment-only JSONC edits and delete/recreate cycles with different content cause conflicts. The missing-file revision includes the intended canonical path. An ABA delete/recreate with identical bytes may retain the same revision; that state is content-equivalent and does not lose a user edit. - -`PATCH /config/overlay?directory=...` accepts exactly one scope per request: - -```ts -interface ConfigOverlayWrite { - scope: ConfigScope - set?: Record - unset?: string[][] - expected: { - path: string - revision: string - } -} - -interface ConfigOverlayWriteResult { - outcome: "applied" | "applied_but_overridden" - changed: boolean - overriddenPaths: string[][] - snapshot: ConfigOverlaySnapshot -} -``` - -The server performs the following transaction: - -1. Resolve the authoritative target from the routed instance directory and requested scope. Never accept `expected.path` as an arbitrary destination. -2. Canonicalize and compare the resolved and expected paths. Reject a changed target, including a newly created higher-priority config file. -3. Acquire a cross-process lock keyed by canonical target path. Project writes must use the same locking discipline already used for global config. -4. Inside the lock, re-resolve the target, read exact bytes, recompute the revision, and reject a mismatch. -5. Apply `set`/`unset` to the raw target layer, preserve JSONC comments where supported, validate the result, and atomically replace the target using a temporary file in the same directory. Do not expose a partially written config. -6. Invalidate the affected config instances, reload the overlay, and return the authoritative post-write snapshot. Do not fabricate optimistic config if refresh fails. -7. Emit a config event containing `scope`, routed `directory`, canonical `target`, and new `revision`. Global changes invalidate all runtime config caches; project changes invalidate only matching directory/project caches. - -`applied_but_overridden` is success. For each `set` leaf, the refreshed overlay identifies whether a higher layer still wins, such as project config shadowing a user value or managed/runtime config shadowing a project value. The UI keeps the saved value and explains why effective behavior did not change. - -Do not send global and project patches in one webview message or pretend that two files save atomically. User and Project scope maintain independent drafts and save independently. VS Code extension preferences such as autocomplete settings are a third, visibly separate store with their own request acknowledgements. - -Domain actions outside Settings must not perform client-side read-modify-write against a mutable effective config. Provider/custom-provider/work-style actions are User-global operations and should call either the same revision-aware User endpoint or a narrow backend mutation that merges named fields under the target lock. Permission Dock "always" rules remain a narrow global rule mutation tied to the exact pending request/session route; that session directory validates the request but does not select a project config target. All such mutations emit the revisioned config event, so a dirty Settings editor becomes stale rather than being overwritten. - -Expected typed failures are: - -- `unknown_project` or `stale_project_generation` from the extension; -- `untrusted_project` or `workspace_untrusted` from the extension; -- `binding_mismatch` or `binding_expired` from the extension; -- HTTP 409 `config_target_changed` with the current target descriptor; -- HTTP 409 `config_revision_conflict` with the current target descriptor and fresh snapshot when available; -- HTTP 403 `config_target_not_writable` or project target escaping its trusted root; -- HTTP 422 `config_invalid` with schema/parse details; -- an I/O failure with no success acknowledgement. - -Every failure preserves the draft and names the scope/project/path. A config event is never treated as confirmation of the user's save; only a matching write response may clear that draft. - -### Draft and project-switch behavior - -- Detail activation from project A to B does not change the Settings selector, binding, values, draft, save state, or target. -- A User Settings draft is global and survives all detail-project switches. -- Project scope uses an explicit trusted project selector that never follows detail activation automatically. -- On first open, the selector may initialize once from the pinned/selected project for convenience, but that choice is immediately captured as Settings state. -- Choosing another Settings project with a dirty draft prompts `Save`, `Discard`, or `Stay`. Drafts are never carried to a different project. -- Clean selector changes issue a new request and ignore out-of-order responses by `requestId`. -- A save captures one binding. Switching visible settings while it is in flight does not redirect it; the result updates only the original binding store and identifies that target in its notification. -- External config events refresh a clean binding. For a dirty or saving binding they mark it stale and show a changed-on-disk banner without replacing local edits. -- A 409 preserves the draft and disables blind retry. The first version offers `Reload`, `Open file`, and `Discard`; it does not auto-merge or force overwrite. A future three-way merge may use the original snapshot, fresh snapshot, and draft. - -### Scope UX - -The first release presents explicit `User | Project` scope, a required selector in Project scope, source badges, and separate binding-keyed drafts. The selected scope applies to the entire operation. Remove hidden `splitConfigByScope`; no setting silently chooses a different file. Inherited user values remain visible in Project scope with `Override`/`Reset to inherited` affordances backed by `set`/`unset`. - -### Project root, worktree, and trust semantics - -- A Project Settings binding targets the registered checkout root held by `ProjectContext.root`. It never targets the last selected Local/worktree/session card. -- Runtime config remains directory-correct: Local uses the project root, and a worktree session uses its exact routed worktree directory. Branch-specific config in a managed worktree can therefore differ at runtime. -- Editing a project's root config does not claim to update already-created worktree checkouts. Editing a worktree config from Settings is out of scope for the first Project Settings release. A future worktree editor must use an explicit `WorktreeRef` and its own immutable binding. -- The backend resolver may choose an existing root or `.kilo`/`.kilocode` config according to current precedence, but the expected target must remain within the trusted project checkout after canonical/symlink resolution. A symlink escape is read-only and must not be patched through the API. -- For an existing target, canonicalize the file with `realpath`. For a missing target, canonicalize its nearest existing ancestor and append the remaining path components before checking containment. Global writes must similarly remain under the canonical `Global.Path.config` root. Do not rely on a lexical prefix check. -- Additional Agent Manager projects must be registry-trusted before project overlay evaluation, opening/creating project config, or project writes. Global User Settings remain editable without trusting a project. -- The pinned project is trusted for config evaluation only when the VS Code workspace is trusted. Do not treat `pinned` as an unconditional trust grant. -- Trust is stored outside the repository in the extension registry/global state. A project config file cannot grant its own trust. Revoking trust invalidates all bindings and project runtime caches. - -### Concrete implementation sequence for config safety - -1. In `packages/opencode/src/kilocode/config/overlay.ts`, replace path-only targets with revisioned target descriptors and add generic changed-path source resolution. -2. Add a Kilo-owned compare-and-swap writer under `packages/opencode/src/kilocode/config/` that resolves, confines, locks, fingerprints, patches, validates, and atomically replaces one target. Keep shared `packages/opencode/src/config/config.ts` changes to minimal marked delegation/invalidation hooks. -3. Update `packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts` and `handlers/config-console.ts` with the write precondition, result, and typed error contracts. Regenerate `packages/sdk/js/`. -4. Extract a config binding controller under `packages/kilo-vscode/src/kilo-provider/` and have `KiloProvider.ts` delegate reads/writes to it. The controller receives explicit project-route resolution; it never reads mutable active workspace state during a bound operation. -5. Replace `configLoaded`, `configUpdated`, and unqualified `updateConfig` in `webview-ui/src/types/messages/` with runtime and Settings message families. Keep a temporary single-project adapter only at the protocol edge. -6. Split `webview-ui/src/context/config.tsx` into activation-bound runtime state and binding-keyed Settings editor state. Settings components consume the editor context; non-settings consumers continue to use runtime-effective config. -7. Remove `webview-ui/src/utils/config-scope.ts` after migrating every control to explicit scope. Preserve explicit project editing for commit messages and repository indexing rules; move indexing enablement to machine-local project consent. -8. Audit all extension config mutators and route user-config imports, resets, custom providers, work-style writes, project writes, and save-bar writes through a revision-aware endpoint or backend atomic field-level mutation. A project-sourced item must never fall back to an active directory write. -9. Make open-file actions take `ProjectRef`, resolve `ProjectContext.root`, verify trust, and show the exact path. For a missing target, open an unsaved document at that explicit path or require an explicit create action; never create it merely by visiting Settings. -10. Update config events and cache invalidation so scope/directory/revision are retained end to end. - -Focused tests belong in: - -- `packages/opencode/test/kilocode/server/config-overlay.test.ts` for raw target snapshots, target changes, 409 revisions, source shadowing, locks, symlink confinement, and response outcomes; -- `packages/opencode/test/kilocode/project-config-update.test.ts` for target selection and atomic writes from repository roots/nested directories; -- a new Kilo-owned backend unit test beside those files only if the compare-and-swap writer needs direct fault-injection coverage; -- `packages/kilo-vscode/tests/unit/config-utils.test.ts`, replacing singleton `ConfigState` assumptions with binding/request-aware draft tests; -- `packages/kilo-vscode/tests/unit/config-scope.test.ts`, deleted when `splitConfigByScope` is removed and replaced by tests proving one explicit scope per save; -- Agent Manager route/context tests for immutable project-root resolution, trust revocation, removal, and generation changes; -- KiloProvider protocol tests for A-to-B activation during reads and writes, out-of-order responses, cache partitioning, and auditing non-save-bar mutators. - -## One `.kilo/agent-manager.json` per project - -Yes, every project has its own repository-local state file: - -```text -project-a/.kilo/agent-manager.json -project-b/.kilo/agent-manager.json -project-c/.kilo/agent-manager.json -``` - -This is already structurally supported because `WorktreeStateManager` derives its file from its immutable constructor root. The final architecture makes this ownership explicit and safe. - -### Ownership rules - -Each `ProjectContext` owns exactly one: - -- canonical repository root and canonical Git common directory; -- `WorktreeStateManager(root)` writing only `/.kilo/agent-manager.json`; -- `WorktreeManager(root)`; -- `SetupScriptService(root)` and project run service; -- diff/import/branch-naming services; -- git stats and PR pollers; -- worktree/session/section/order/run/stale caches; -- mutation queue and initialization promise. - -The global project registry stores descriptors only. It never stores worktrees, sections, sessions, tab order, or repository behavior. The pinned project is derived from the current workspace and remains outside the registry. - -### Initialization - -The first explicit expansion of a trusted project calls one single-flight `ensureReady()` promise: - -1. validate root and Git common directory; -2. construct project-owned services; -3. update that repository's local Git exclude; -4. load only that repository's `.kilo/agent-manager.json`; -5. discover and reconcile only that repository's worktrees; -6. register Local and worktree session routes for that project; -7. start context-owned polling if the project is expanded and the panel is visible; -8. post one generation-stamped project snapshot. - -Repeated expansion must reuse the same initialization promise. Collapse/removal during initialization records the desired lifecycle transition and prevents late completion from posting or mutating a replacement context. - -### Writes and disposal - -All mutations for one project execute through that context's serialized mutation queue. An operation captures the context and generation before its first await and never re-resolves through `contexts.active()` afterward. - -Collapse: - -- leaves the project registered; -- stops pollers/watchers after current operations reach a safe boundary; -- flushes its own state file; -- retains session routes required for already-running backend sessions; -- never aborts a session merely because the UI collapsed. - -Removal: - -- never deletes `.kilo/agent-manager.json`, worktrees, branches, or sessions; -- refuses removal while a project mutation/run requires ownership, unless an explicit coordinated shutdown is implemented; -- awaits context disposal and state flush before deleting the registry descriptor; -- invalidates context generation before awaits so late callbacks are ignored. - -Panel disposal awaits all initialized contexts before shared services are disposed. - -Two VS Code windows writing the same repository state file remain the same pre-existing concurrency boundary as today's single-project Agent Manager. This plan prevents one panel from creating two contexts for the same Git common directory; cross-window file coordination is not expanded in this feature. - -## ProjectContext lifecycle - -`ProjectContext` becomes a lifecycle owner rather than only a lazy service container. - -```ts -type ProjectLifecycle = - | "cold" - | "initializing" - | "ready" - | "suspended" - | "disposing" - | "disposed" - -interface ProjectContext { - readonly id: ProjectId - readonly root: string - readonly commonGitDir: string - readonly generation: number - ensureReady(): Promise - run(operation: (ctx: ReadyProjectContext) => Promise): Promise - suspend(): Promise - dispose(): Promise -} -``` - -Every async init, poll, mutation, diff, run, import, setup, PR update, branch naming, and session operation checks context identity and generation before committing state or posting to the webview. - -## Strict project-qualified protocol - -When multi-project mode exposes more than one project, every repository operation requires project identity. - -Required project qualification includes: - -- worktree create/delete/rename/import/order/section/open/PR actions; -- Local and worktree session create/open/close/fork/promote/share/unshare actions; -- prompt, abort, permission, question, command, sandbox, and context actions; -- terminal create/input/resize/close/show actions; -- diff/watch/apply/revert actions; -- run/setup/configuration actions; -- all state/session/diff/run/terminal/setup/PR output messages. - -Missing, unknown, mismatched, inactive-when-required, or ambiguous identities return a typed route error. They never fall back to the current project or `workspaceFolders[0]`. - -Single-project mode retains a narrow compatibility adapter that injects the pinned project identity. Core handlers always operate on explicit references. - -## One KiloProvider with a project/session route service - -Keep one embedded `KiloProvider`. Multiple providers would duplicate event handling, terminal registrations, caches, and current-session state for one webview. - -Add an Agent Manager route service: - -```ts -interface SessionRoute extends SessionRef { - directory: string - generation: number -} -``` - -It must: - -- register explicit routes for every Local session at its project root; -- register every worktree session at its worktree path; -- let child sessions inherit the parent route; -- validate directory ownership; -- detect ambiguous raw session IDs; -- supply the exact directory to every SDK operation, including share/unshare; -- remove current-root and workspace-root fallback for identified Agent Manager sessions; -- refresh sessions independently for every initialized project and emit real project-stamped `SessionInfo` records. - -The shared detail pane consumes the selected `SessionRef`. Every sidebar body consumes its project's real session store. Background session UI may initially be visually simpler, but it must not display fabricated titles or placeholder session objects. - -Project activation in `KiloProvider` must invalidate and refresh project-scoped config, providers, agents, commands, skills, MCP state, indexing, sandbox state, and Git status under an activation generation guard. - -## Global services with composite keys - -Some services remain panel-global to avoid duplicate VS Code registrations, but all associations use composite references. - -- terminal mappings: `ProjectId + SessionId` or `ProjectId + WorktreeId`; -- run state: keyed by `WorktreeRef`, including project Local; -- visible/presence state: keyed by `SessionRef`; -- tabs and sidebar selection: keyed by `SidebarTarget`; -- diff/apply/revert state: keyed by `ProjectRef` and target; -- pending setup/create/delete state: keyed by project-qualified resource refs. - -Remove unknown-worktree-to-current-root terminal fallback. A context supplies a validated CWD to the global terminal manager. - -Global backend events are routed once by event directory and registered session route. Status, tool, orchestration, permission, and question events retain their directory and enter exactly one owning project context. A cold/collapsed context is not initialized by an unsolicited event. - -## Uniform project sidebar UI - -### One body implementation - -Extract the existing real `renderBody()` from `AgentManagerApp.tsx` into `ProjectSidebarBody`. Do not maintain active and background implementations. - -`ProjectSidebarBody` receives a stable project-scoped store and project-qualified action callbacks. It reuses: - -- the existing Local card markup; -- `WorktreeSectionActions`; -- `WorktreeItem`; -- `SectionHeader` and current drag/drop behavior; -- `UnassignedSessionsSection`; -- existing rename/delete/open/PR/run/setup/session affordances. - -Render it for every expanded project: - -```tsx - - {(project) => ( - - - - - - )} - -``` - -The body is keyed by `projectId`, not active status. Changing detail selection cannot remount it. - -### Per-project webview stores - -Replace active-only shared sidebar signals with `Map` or an equivalent Solid store registry. - -Each store owns: - -- state/worktrees/sections/order/stale state; -- real `SessionInfo` and managed-session state; -- git stats and local stats; -- PR and run/setup state; -- pending delete/rename/create state; -- sidebar search/order/collapse state where repository-owned; -- generation for dropping stale output. - -Only shared detail/chat/terminal state remains global. The top toolbar reads the selected detail target's project store. - -`project-live.ts` may be evolved into this complete store registry. It must stop mirroring active payloads into a separate sidebar signal set. - -### Scrolling and layout - -The project list becomes the sidebar scroll container. Individual project bodies must not use a global `50vh` cap that makes two projects fight for viewport height. Section and project collapse state controls density; normal browser scrolling exposes all expanded worktrees. - -### UI behavior - -- Clicking a Local/worktree/session card emits one atomic project-qualified selection. -- Rename/delete/run/open/section/PR actions execute against the clicked card's project without changing sidebar structure. -- The active project may receive an emphasis style, but no different markup. -- Active project chevrons are not disabled; expansion and detail selection are separate concepts. -- Global New Session/Run buttons target the last selected detail project. -- New Worktree accepts an explicit target project, defaulting to the selected detail project. - -## Uniform polling and live state - -Every ready expanded context owns the same poller set. There is no active singleton poller plus background poller split. - -Pollers emit `{ projectId, generation }` and update only their owning context/store. Stop/suspend increments generation before clearing timers so in-flight Git/PR results are dropped. - -Context state is pushed after every project mutation, not only initial expansion or active-project changes. This includes worktree/session/section/order/run/setup changes in background projects. - -Panel visibility changes poll cadence uniformly across all expanded contexts. - -## Registry correctness - -Registry mutations use one in-process queue. Each mutation re-reads persisted storage, validates it, applies one mutation, writes once, then updates memory. Trust/remove persistence failures are user-visible and do not leave UI state ahead of storage. - -Registry descriptors include canonical root, canonical Git common directory, URI scheme/authority, order, label, trust, and added time. They do not contain Agent Manager repository state. - -## Implementation order - -### Slice 0: configuration safety blocker - -1. Introduce revisioned `/config/overlay` reads and compare-and-swap writes, first for User scope and with the same generic implementation covered for Project scope. -2. Separate runtime-effective config messages/state from immutable Settings bindings and drafts. -3. Add explicit `User | Project` Settings scope and an immutable trusted project selector/binding for Project writes. -4. Remove hidden `splitConfigByScope` persistence and audit every direct/indirect config mutation, including provider disconnect, import/reset, custom providers, work-style presets, Permission Dock, and indexing. -5. Make config events scope/directory/target/revision aware and generation-guard runtime activation refreshes. -6. Add target-switch, stale-binding, revision-conflict, trust, and draft-preservation tests. - -Do not enable multi-project broadly before this slice passes. - -### Slice 1: routing and identity blockers - -1. Add project-qualified refs and strict protocol types. -2. Add the project/session route service and explicit Local routes. -3. Remove Agent Manager current/workspace-root fallback for identified operations. -4. Add atomic project-qualified selection. -5. Convert panel-global maps to composite keys. -6. Add strict routing and raw-ID collision tests. - -Do not expose interactive background controls before this slice passes. - -### Slice 2: context lifecycle and service ownership - -1. Add single-flight initialization, lifecycle state, generation, and mutation queue to `ProjectContext`. -2. Move root-bound diff/import/run/setup/branch-naming/poller ownership behind context APIs. -3. Replace dynamic active getters inside async mutations with captured contexts. -4. Add suspend/remove/dispose coordination. -5. Reject duplicate Git common directories and serialize registry writes. -6. Route global backend events by directory. - -### Slice 3: complete per-project stores - -1. Refresh and store real sessions per project. -2. Emit all relevant output with project and generation. -3. Evolve `project-live.ts` into a complete project sidebar store registry. -4. Make polling uniform and generation-safe. -5. Add background mutation/state propagation tests. - -### Slice 4: uniform real UI - -1. Extract the current `renderBody()` intact into `ProjectSidebarBody`. -2. Parameterize it by one project store and project-qualified callbacks. -3. Render one body per expanded project. -4. Delete `ProjectSummary` and its CSS. -5. Separate project expansion from detail activation. -6. Add project-aware New Worktree targeting. -7. Add component/visual tests proving no body remount or structural change on selection. - -### Slice 5: validation and release gating - -1. Run focused lifecycle/router/context tests after each slice. -2. From `packages/opencode/`, run the focused config overlay/project-update tests and CLI typecheck. Run the opencode annotation and Promise-facade guards for shared-file hooks. -3. Regenerate the JS SDK after endpoint schema changes and verify generated consumers compile; never hand-edit generated sources. -4. From `packages/kilo-vscode/`, run typecheck, lint, focused/unit tests, knip, compile/package, and architecture guards. -5. Manually exercise two repositories with multiple worktrees and sessions in both, including a dirty User draft during project activation, an external config edit conflict, project trust revocation, project removal, and project-config open targeting. -6. Keep the feature flag default off until all acceptance criteria pass. - -## Blocking tests - -The feature is not complete until tests prove: - -1. Two concurrent expands initialize one context once. -2. Collapse/remove during initialization produces no late state or poller posts. -3. A project switch during create/import/setup/session/run/diff cannot change operation ownership. -4. Project B Local sessions never resolve through project A. -5. Missing/mismatched project references fail closed. -6. Poller/PR late results after stop or generation change are dropped. -7. Same raw worktree/session/local IDs in two projects do not collide in runtime or UI stores. -8. Two expanded projects render two permanent full `ProjectSidebarBody` instances. -9. Selecting a card changes only shared detail state and active emphasis, not body structure or mount identity. -10. Every worktree/session/terminal/diff/run/setup/section/PR action targets the clicked project's context. -11. Registry writes preserve concurrent add/remove/trust changes. -12. Roots sharing one Git common directory cannot both register. -13. Panel shutdown awaits all contexts and stops background activity. -14. Flag-off behavior remains the current single-project UI and state file. -15. A Settings draft loaded for project A cannot write project B after any activation, expansion, removal, trust, or worktree switch. -16. User Settings writes target the revisioned user file; explicit Project Settings writes target only the selected trusted project's revisioned root config. No key is silently rerouted. -17. A clean external config change refreshes Settings; a dirty or saving binding is marked stale without losing its draft. -18. Concurrent editors using the same revision produce one success and one 409 conflict, with no lost update or partial file. -19. A comment-only JSONC edit, file deletion/recreation with changed bytes, or newly created higher-priority config changes the revision/target and rejects the stale write. -20. A save response clears only the matching `{ requestId, binding.id }`; an SSE config event or out-of-order read cannot confirm a save. -21. Global and project writes are independently acknowledged and never presented as one atomic transaction. -22. Project config open/read/write actions reject unknown, untrusted, removed, generation-stale, symlink-escaping, or mismatched project targets. -23. Runtime config for a selected managed worktree uses that worktree directory, while Project Settings/open-file targets the immutable registered project root. -24. Project-sourced providers and other config collections cannot trigger an implicit active-project write from Settings actions. - -## Acceptance criteria - -- No `ProjectSummary` or alternate fake-card body exists. -- Every expanded project displays real live Local, worktree, and session UI concurrently. -- Every visible worktree/session action is usable and routes by explicit project identity. -- Switching detail selection causes no sidebar body remount, replacement, collapse, or data reset. -- Each project reads and writes only its own `.kilo/agent-manager.json`. -- Expanded projects remain live independently; collapsed projects suspend safely. -- No identified operation falls back to active/current/workspace root. -- Single-project behavior and persisted state remain backward-compatible when the flag is off. -- Shared Settings exposes explicit User and Project scope; Project writes require a trusted project selector and immutable binding. -- Runtime project activation and Settings editor binding are separate state machines. -- Every Settings config write has an immutable scope/target binding and revision precondition; narrow domain mutations merge named fields under the same target lock. No config mutation uses a save-time active-directory lookup. -- Dirty drafts survive external updates and conflicts and can never migrate between projects. -- Project Settings targets the explicit registered root, never the active Agent Manager project or active worktree. - -## Planned UI - -```text -┌─ Sidebar ─────────────────────────────────┬─ Shared detail pane ──────────┐ -│ ← selected project search calendar gear │ │ -│ │ Chat / diff / terminal │ -│ PROJECTS + │ for the selected │ -│ ▾ abalone-bactrosaurus │ project/worktree/session │ -│ ┌─────────────────────────────────┐ │ │ -│ │ Local main +3295 -548 ↓54 │ │ │ -│ └─────────────────────────────────┘ │ │ -│ WORKTREES actions │ │ -│ ┌─────────────────────────────────┐ │ │ -│ │ feature-x #1234 +120 -8 ↓2 │ │ │ -│ │ real menus, rename, run, open │ │ │ -│ └─────────────────────────────────┘ │ │ -│ ┌─────────────────────────────────┐ │ │ -│ │ bugfix-y +45 -12 │ │ │ -│ └─────────────────────────────────┘ │ │ -│ SESSIONS │ │ -│ • Fix login redirect running │ │ -│ • Refactor auth │ │ -│ │ │ -│ ▾ kilo-pi-provider │ │ -│ ┌─────────────────────────────────┐ │ │ -│ │ Local master +16 -6 ↓1 │ │ │ -│ └─────────────────────────────────┘ │ │ -│ WORKTREES actions │ │ -│ ┌─────────────────────────────────┐ │ │ -│ │ provider-z #88 +88 -3 │ │ │ -│ │ real menus, rename, run, open │ │ │ -│ └─────────────────────────────────┘ │ │ -│ SESSIONS │ │ -│ • Add provider tests │ │ -│ │ │ -│ ▸ docs-repo collapsed │ │ -│ │ │ -│ [New Session] [Run] │ │ -└───────────────────────────────────────────┴──────────────────────────────┘ - -Selecting any card: - -- keeps every project body mounted and pixel-stable; -- changes only the selected styling, top toolbar target, and shared detail pane; -- never swaps a real body for a summary or vice versa. -``` diff --git a/.kilo/plans/swarm-enabled-by-default.md b/.kilo/plans/swarm-enabled-by-default.md new file mode 100644 index 0000000000..9fa1d58dc0 --- /dev/null +++ b/.kilo/plans/swarm-enabled-by-default.md @@ -0,0 +1,124 @@ +# Enable Kilo Swarm (shared agent board) by default + +Goal: Kilo Swarm, the experimental shared agent board (tools `board_read` / `board_post`), +is enabled for every session by default. Users can opt out through a normal settings surface. +No renames of the config key, tool names, permissions, stored IDs, or migrations. + +## Decisions + +- D1. Default state: enabled. The board is active when the config key is absent and no env flag is set. +- D2. Opt-out is explicit. The board is disabled only when: + - `experimental.shared_agent_board` in config is exactly `false`, or + - `KILO_EXPERIMENTAL_SHARED_AGENT_BOARD` is set to a falsy boolean (`false`, `no`, `off`, `0`, `n`). + Either explicit disable wins. There is no "env true beats config false" rule anymore. +- D3. `KILO_EXPERIMENTAL` (umbrella) no longer toggles this feature. It stays enabled regardless. + `KILO_EXPERIMENTAL_SHARED_AGENT_BOARD=true` remains accepted but is redundant. +- D4. Implementation follows the existing `experimentalBackgroundSubagents` precedent: + `RuntimeFlags.experimentalSharedAgentBoard` is a boolean that defaults to `true` with an + opt-out kill switch. `BoardEnabled.resolve` becomes an opt-out predicate over `config` and `flag`. +- D5. `BoardEnabled.resolve` keeps its `{ config?: boolean; flag?: boolean }` signature and its + call sites. Only the returned logic changes. +- D6. Config schema is unchanged. `experimental.shared_agent_board` still decodes to `undefined` + when absent, so `packages/core` config tests stay valid. +- D7. VS Code UI treats an absent config key as enabled: + the Experimental toggle starts checked and the Board header gate allows the board. +- D8. Non-goals: no new settings key, no changes to board tool behavior, permissions, storage, + migrations, or the SDK schema. No change to JetBrains beyond inheriting the CLI default. +- D9. Two verification-only test files (`test/kilocode/tool-registry-indexing.test.ts`, + `test/kilocode/board-context.test.ts`) had expectations that encoded the old opt-in default. + Their ownership was extended to unit A for pure expectation updates only; no source logic changed. +- D10. D2 note: "falsy boolean" means `Config.boolean` falsy values. An empty string is a parse + failure, matching pre-existing `Config.boolean` behavior. +- D11. (supersedes D7's placement) The user-facing opt-out toggle moves from the **Experimental** + tab to the **Agent Behaviour** tab, agents subtab, so it reads as a normal setting. The config + key `experimental.shared_agent_board` and the existing i18n keys stay unchanged. No CLI change. +- D12. The `settings--agent-behaviour-agents` visual-regression baseline changes. The + visual-regression workflow auto-generates and commits baselines on internal PRs, so no local + baseline edit is required. + +## Tasks + +### A. CLI default-on semantics (owner: packages/opencode + changeset) - DONE +Owns: `src/effect/runtime-flags.ts`, `src/kilocode/board/enabled.ts`, board-enabled/board-tools/ +runtime-flags tests, `.changeset/swarm-enabled-by-default.md`. + +### B. VS Code defaults and docs (owner: packages/kilo-vscode + packages/kilo-docs) - DONE +Owns: `ExperimentalTab.tsx`, `SwarmBoard.tsx`, doc pages, changeset wording. + +### C. Verification of A and B - DONE + +### D. Move the opt-out toggle to Agent Behaviour (owner: packages/kilo-vscode + docs) +Owns: +- `packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx` +- `packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx` +- `packages/kilo-docs/pages/getting-started/settings/index.md` +- `packages/kilo-docs/pages/automate/tools/index.md` +- `packages/kilo-docs/pages/automate/agent-manager.md` +- `.changeset/swarm-enabled-by-default.md` +Provides: the relocated setting; needs the existing config key only. + +### E. Verification of D +Owns: no source files. + +## Acceptance criteria (unit D) + +1. The Kilo Swarm switch no longer renders in the Experimental tab, and renders in the + Agent Behaviour tab's agents subtab. +2. The switch keeps the `experimental.shared_agent_board` key, defaults to checked when the key + is absent, and persists `false` to global config when switched off. +3. Docs say the opt-out lives in **Settings > Agent Behaviour** and no longer say Experimental. +4. The changeset describes the default-on behavior and the Agent Behaviour opt-out. +5. VS Code typecheck and focused unit tests pass. No other file changes. + +## Findings + +### A. CLI default-on semantics +Done. `BoardEnabled.resolve` is opt-out (`config === false` then `flag === false`, else `true`). +`RuntimeFlags.experimentalSharedAgentBoard` is `Config.boolean(...).pipe(Config.withDefault(true))` +behind kilocode_change markers. Tests green: board-enabled 9/9, board-tools 8/8, runtime-flags +39/39, tool-registry-indexing 16/16, board-context 12/12; typecheck clean. Fail-without-fix: +reverting both source edits made board-enabled fail 5/9 and board-tools fail 1/8. + +### B. VS Code defaults and docs +Done. `ExperimentalTab.tsx:186` and `SwarmBoard.tsx:59` treat an absent key as enabled. Docs +updated. VS Code typecheck exit 0; full unit suite 5658 pass / 0 fail. No other client-side gate. + +### C. Verification of A and B +Independent verifier green on all criteria, including a safe fail-without-fix revert. Residual +notes: `agent/index.ts` cacheKey stores the raw config value (harmless); empty-string env is a +parse failure. + +### D. Move the toggle to Agent Behaviour +Done. The Kilo Swarm `SettingsRow` was removed from `ExperimentalTab.tsx` and inserted in +`AgentBehaviourTab.tsx` between the "Default agent" Select and "Push fixes" Switch (Push fixes +keeps `last`). It uses the existing `experimental.shared_agent_board` key, defaults checked +(`?? true`), and writes through `updateConfig`. The three docs pages and the changeset now point +at **Settings > Agent Behaviour**. No CLI, config-key, i18n, or permission change. + +### E. Verification of D +Verified by main on the combined diff: the switch renders only in `AgentBehaviourTab.tsx:313-323` +(ExperimentalTab has zero references); `bun run typecheck` clean; `bun run lint` exit 0; +`bun test tests/unit/config-utils.test.ts tests/unit/config-scope.test.ts` 38 pass / 0 fail; +prettier clean on both TSX files. `settings--agent-behaviour-agents` baseline changes; the +visual-regression workflow regenerates and commits it on internal PRs. + +VS Code self-test (`vscode-self-test`, isolated dev instance, headless, disposable profile): +- Fresh launch, Settings > Agent Behaviour (agents subtab): the Kilo Swarm row is present and its + switch is `aria-checked="true"` by default. +- Settings > Experimental: zero Kilo Swarm rows. +- Toggling the switch off and clicking Save wrote `"shared_agent_board": false` into the isolated + global `kilo.jsonc`, and the save bar cleared. +- After a full VS Code restart with the same profile, the switch rendered `aria-checked="false"`, + confirming the opt-out persists. +- Isolated instance cleaned up and the profile removed. The extension build regenerated + `packages/sdk/js/src/v2/gen/sdk.gen.ts` (an unrelated doc-comment drift); it was reverted. + +## Review log +- A (CLI default-on): `ship`. +- B (VS Code defaults + docs): `ship`. +- C (independent verification): `ship`. +- Integration follow-up (main): removed the stale word "optional" from + `customize/custom-subagents.md`; formatted `runtime-flags.test.ts` with prettier. +- D (move toggle to Agent Behaviour): `ship`. Diff reviewed; typecheck/lint/tests green; VS Code + self-test confirmed default-on rendering, absence from Experimental, the persisted `false`, and + persistence across a restart. diff --git a/.opencode-version b/.opencode-version index 2d0c27ad10..cd4b63a81c 100644 --- a/.opencode-version +++ b/.opencode-version @@ -1 +1 @@ -v7.6.2 +v7.7.3 diff --git a/AGENTS.md b/AGENTS.md index f74ebd6d72..9716489591 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,6 @@ # AGENTS.md -czcode is a fork of [kilocode](https://github.com/Kilo-Org/kilocode) (which forks opencode), specialized for ClickZetta Lakehouse data teams. - -Fork chain: **opencode → kilocode → czcode** +Kilo CLI is an open source AI coding agent that generates code from natural language, automates tasks, and supports 500+ AI models. - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. - The default branch in this repo is `main`. @@ -11,66 +9,55 @@ Fork chain: **opencode → kilocode → czcode** ## Build and Dev -- **Dev**: `~/.bun/bin/bun dev` (runs from root) -- **Dev with params**: `~/.bun/bin/bun dev -- help` -- **Typecheck**: `~/.bun/bin/bun turbo typecheck` (uses `tsgo`, not `tsc`) -- **Test**: `~/.bun/bin/bun test` from `packages/opencode/` (NOT from root — root blocks tests) -- **Single test**: `~/.bun/bin/bun test ./test/tool/tool-define.test.ts` from `packages/opencode/` -- **czcode_change check**: `~/.bun/bin/bun run script/check-opencode-annotations.ts` from repo root. CI runs this on PRs touching `packages/opencode/` — every czcode-specific change in shared files must be annotated with `czcode_change` markers. Note: this check may fail on upstream merge PRs (expected — upstream changes don't need czcode markers). Exempt paths (no markers needed): `packages/czcode-lakehouse/`, and czcode-only files (files with `czcode` in the name that don't exist in kilocode upstream). -- **Upstream sync**: `~/.bun/bin/bun run script/upstream/list-versions.ts` to see available kilocode versions; `~/.bun/bin/bun run script/upstream/merge.ts v7.x.y` to merge. +- **Dev**: `bun run dev` (runs from root) or `bun run --cwd packages/opencode --conditions=browser src/index.ts` +- **Dev with params**: `bun dev -- help` +- **Extension**: `bun run extension` (build + launch VS Code with the extension in dev mode). Pass `--no-build` to skip the build. When asked to run an isolated VS Code/Kilo environment, use the CLI scripts instead of interactive launch configs: `bun run extension:isolated` reuses `.kilo-dev/`, and `bun run extension:isolated:clean` clears `.kilo-dev/` first. Pass an optional workspace path after `--`, for example `bun run extension:isolated -- ../sample-project`. +- **Typecheck**: `bun turbo typecheck` (uses `tsgo`, not `tsc`). Includes the JetBrains plugin and requires Java 21; do not run `java -version` as a routine preflight. Only check Java when a Gradle/Java command fails with a Java-version or missing-Java error. If missing, install via SDKMAN: `sdk install java 21-tem && sdk use java 21-tem`. If SDKMAN is not installed, see https://sdkman.io/install. +- **Test**: `bun test` from `packages/opencode/` (NOT from root -- root blocks tests) +- **Single test**: `bun test ./test/tool/tool-define.test.ts` from `packages/opencode/` +- **CLI build artifact size check**: after `bun run script/build.ts --single --skip-install` in `packages/opencode/`, use `du -h dist/*/*/bin/kilo` (scoped package output lives under `dist/@kilocode/`) +- **SDK regen**: After changing server endpoints in `packages/opencode/src/server/`, run `./script/generate.ts` from root to regenerate `packages/sdk/js/` +- **Knip** (unused exports): `bun run knip` from `packages/kilo-vscode/`. CI runs this — all exported types/functions must be imported somewhere. Remove or unexport unused exports before pushing. +- **Source links**: After adding or changing URLs in `packages/kilo-vscode/`, `packages/kilo-vscode/webview-ui/`, or `packages/opencode/src/`, run `bun run script/extract-source-links.ts` from the repo root and commit the updated `packages/kilo-docs/source-links.md`. CI runs this check — the build fails if the file is stale. +- **kilocode_change check**: `bun run check-kilocode-change` from `packages/kilo-vscode/`. CI runs this — `kilocode_change` is a marker for upstream merge conflicts and must not appear in `packages/kilo-vscode/` or `packages/kilo-ui/` (these are entirely Kilo Code additions). Remove the markers before pushing. +- **opencode annotation check**: `bun run script/check-opencode-annotations.ts --worktree` from repo root when verifying local agent changes. CI runs `bun run script/check-opencode-annotations.ts` on PRs touching `packages/opencode/` — every Kilo-specific change in shared opencode files must be annotated with `kilocode_change` markers. Exempt paths (no markers needed): `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`, and any path containing `kilocode` in the name. +- **Effect facade ratchet**: Do not add runtime-backed Promise facades to shared `packages/opencode/src` Effect services; use service dependencies, `AppRuntime`, or Kilo-owned boundaries. Run `bun run script/check-opencode-promise-facades.ts` when touching service adapters. +- **workflow allowlist**: `bun run script/check-workflows.ts` from repo root. CI runs this as part of the annotations workflow — any `.yml` / `.yaml` file added to or removed from `.github/workflows/` must be reflected in the hardcoded list in `script/check-workflows.ts`. Prevents upstream-merged workflows from silently starting to run in our CI. +- **Backend/SDK programmatic testing**: see [TESTING.md](./TESTING.md) for spawning the local main-branch backend (`bun dev serve`) and driving it via `curl` — use this instead of `kilo serve` (prod binary) when testing backend fixes. ## Quality Checks -Before saying an implementation is ready, run the smallest relevant checks that can catch lint, typecheck, and test failures for the touched package. +Before saying an implementation is ready, run the smallest relevant checks that can catch lint, typecheck, and test failures for the touched package. Do not rely on manual extension launch to discover build problems. Fix failures you introduced before the final response, or state exactly which check is still failing or could not be run. | Area | Checks | |---|---| -| Root / cross-package | `~/.bun/bin/bun run lint`, `~/.bun/bin/bun run typecheck` | -| CLI | From `packages/opencode/`: `~/.bun/bin/bun run typecheck`, `~/.bun/bin/bun test` | -| CI-only guards | `~/.bun/bin/bun run script/check-opencode-annotations.ts` | +| Root / cross-package | `bun run lint`, `bun run typecheck` | +| CLI | From `packages/opencode/`: `bun run typecheck`, `bun test` or targeted `bun test ./path/to/file.test.ts` | +| VS Code extension | From `packages/kilo-vscode/`: `bun run typecheck`, `bun run lint`, `bun run test:unit` or `bun run test` | +| Extension build/package | From `packages/kilo-vscode/`: `bun run compile` or `bun run package` when touching build, packaging, SDK, or webview integration paths | +| JetBrains plugin | From `packages/kilo-jetbrains/`: `./gradlew typecheck`, `./gradlew test`. Requires Java 21; do not run `java -version` as a routine preflight. Check Java only after a Java-version or missing-Java failure. | +| CI/local guards | Run affected guards documented above, such as `bun run knip`, `bun run check-kilocode-change`, `bun run script/check-opencode-annotations.ts --worktree`, or source link extraction | -Never run root `bun test`; the root script prints `do not run tests from root` and exits with code 1. +Never run root `bun test`; the root script prints `do not run tests from root` and exits with code 1. Use package-level tests instead. ## Products -All products are clients of the **CLI** (`packages/opencode/`), which contains the AI agent runtime, HTTP server, and session management. +All products are clients of the **CLI** (`packages/opencode/`), which contains the AI agent runtime, HTTP server, and session management. Each client spawns or connects to a `kilo serve` process and communicates via HTTP + SSE using `@kilocode/sdk`. | Product | Package | Description | |---|---|---| -| czcode CLI | `packages/opencode/` | Core engine. TUI, `czcode run`, `czcode serve`. Fork of kilocode. | -| czcode Lakehouse Plugin | `packages/czcode-lakehouse/` | ClickZetta Lakehouse tools: read_query, write_query, list_objects, describe_object, explain_query, get_context, switch_context. | -| czcode TUI Plugins | `packages/opencode/src/kilocode/plugins/czcode-*.tsx` | 10 TUI plugins: connection status, schema browser, VCluster dashboard, role switch, SQL history, sample, count, profile, SingClaw, dotenv loader. | -| SingClaw Integration | `packages/opencode/src/kilocode/singclaw/` | Full-screen SingClaw chat via WebSocket RPC. | - -## Data Agent Roles - -czcode has 5 data-specific agent roles (switch via Tab or `/cz_role`): - -| Role | Agent ID | Permissions | -|------|----------|-------------| -| 数据分析师 (default) | `lh-analyst` | SELECT only (read_query, no write_query/bash) | -| 数据工程师 | `lh-engineer` | DDL + DML + SELECT (write_query with confirmation) | -| 数据科学家 | `lh-data-scientist` | DDL + DML + SELECT + bash (with confirmation) | -| 数据运维 | `lh-dba` | VCluster ops + DDL (with confirmation) | -| 数据治理 | `lh-governance` | GRANT/REVOKE/POLICY (with confirmation) | - -Agent prompts: `packages/opencode/src/agent/prompt/lh-*.txt` -Shared base: `packages/opencode/src/agent/prompt/lh-base.txt` -Agent definitions: `packages/opencode/src/kilocode/agent/index.ts` - -## czcode Commands - -| Command | Alias | Description | -|---------|-------|-------------| -| `/cz_role` | `/cz_r` | Switch data agent role | -| `/cz_sample` | `/cz_s` | Quick table sampling | -| `/cz_count` | `/cz_c` | Table row count | -| `/cz_profile` | `/cz_p` | Data quality profiling | -| `/cz_vcluster` | `/cz_vc` | VCluster status | -| `/cz_sql_history` | `/cz_sh` | Browse/copy past SQL | -| `/cz_singclaw` | `/singclaw` | Open SingClaw chat | -| `/cz_skill-update` | — | Update skills | -| `/cz_skill-fix` | — | Fix skill locally | +| Kilo CLI | `packages/opencode/` | Core engine. TUI, `kilo run`, `kilo serve`. Fork of upstream OpenCode. | +| Kilo VS Code Extension | `packages/kilo-vscode/` | VS Code extension. Bundles the CLI binary, spawns `kilo serve` as a child process. Includes the **Agent Manager** — a multi-session orchestration panel with git worktree isolation. | + +**Agent Manager** refers to a feature inside `packages/kilo-vscode/` (extension code in `src/agent-manager/`, webview in `webview-ui/agent-manager/`). It is not a standalone product. See the extension's `AGENTS.md` for details. + +In each VS Code extension host, one `KiloConnectionService` is created for the sidebar, every Kilo editor tab, and Agent Manager; it lazily starts and reuses one current `kilo serve` backend at a time. Agent Manager worktree sessions pass a directory context to this shared backend rather than starting one per worktree. State captured by the active service layer, such as Snapshot `trackState`, is shared across those requests; only directory-keyed `InstanceState` data is isolated. + +Extension-specific settings should live in the Kilo extension settings, not default VS Code settings, unless they are intentionally VS Code-wide. Experimental flags should follow existing flag patterns, not VS Code settings; they usually belong in the Kilo Experimental settings section. + +## Package Instructions + +- When a task primarily touches `packages/kilo-jetbrains/`, read `packages/kilo-jetbrains/AGENTS.md` before planning or editing. It covers split-mode architecture, IntelliJ source lookup, threading fundamentals, UI guidelines, and session component architecture. ## Monorepo Structure @@ -78,11 +65,23 @@ Turborepo + Bun workspaces. The packages you'll work with most: | Package | Name | Purpose | |---|---|---| -| `packages/opencode/` | `@kilocode/cli` | Core CLI — agents, tools, sessions, server, TUI. Most work happens here. | -| `packages/czcode-lakehouse/` | `@czcode/lakehouse` | Lakehouse plugin — czcode-specific, no annotation markers needed. | -| `packages/sdk/js/` | `@kilocode/sdk` | Auto-generated TypeScript SDK. Do not edit `src/gen/` by hand. | -| `packages/plugin/` | `@kilocode/plugin` | Plugin/tool interface definitions. | -| `packages/util/` | `@opencode-ai/util` | Shared utilities. | +| `packages/opencode/` | `@kilocode/cli` | Core CLI -- agents, tools, sessions, server, TUI. This is where most work happens. | +| `packages/sdk/js/` | `@kilocode/sdk` | Auto-generated TypeScript SDK (client for the server API). Do not edit `src/gen/` by hand. | +| `packages/kilo-vscode/` | `kilo-code` | VS Code extension with sidebar chat + Agent Manager. See its own `AGENTS.md` for details. | +| `packages/kilo-gateway/` | `@kilocode/kilo-gateway` | Kilo auth, provider routing, API integration | +| `packages/kilo-telemetry/` | `@kilocode/kilo-telemetry` | PostHog analytics + OpenTelemetry | +| `packages/kilo-i18n/` | `@kilocode/kilo-i18n` | Internationalization / translations | +| `packages/kilo-ui/` | `@kilocode/kilo-ui` | SolidJS component library shared by the extension webview and docs screenshot stories | +| `packages/util/` | `@opencode-ai/util` | Shared utilities (error, path, retry, slug, etc.) | +| `packages/plugin/` | `@kilocode/plugin` | Plugin/tool interface definitions | + +## Commits and PR Titles + +Use conventional commit-style messages and PR titles: `type(scope): summary`. + +Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes are optional; use the affected package or area when helpful, e.g. `core`, `opencode`, `tui`, `app`, `desktop`, `sdk`, or `plugin`. + +Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`. ## Style Guide @@ -99,7 +98,7 @@ Turborepo + Bun workspaces. The packages you'll work with most: ### Avoid let statements -Prefer `const`. Good: `const foo = condition ? 1 : 2`. Bad: `let foo; if (condition) foo = 1; else foo = 2`. +Prefer `const`. Replace `let` + if/else assignment with a ternary or an IIFE. Reassignment is the only legitimate reason to reach for `let`. ### Naming Enforcement (Read This) @@ -107,123 +106,109 @@ THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE. - Use single word names by default for new locals, params, and helper functions. - Multi-word names are allowed only when a single word would be unclear or ambiguous. +- Do not introduce new camelCase compounds when a short single-word alternative is clear. +- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible. - Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`. +- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`. ### Avoid else statements -Prefer early returns. Good: `if (condition) return 1; return 2`. Bad: `if (condition) return 1; else return 2`. +Prefer early returns (or an IIFE) over `else`. After an `if` that returns/throws, the `else` is redundant. ### No empty catch blocks -Never leave a `catch` block empty. Log it or rethrow. +Never leave a `catch` block empty. An empty `catch` silently swallows errors and hides bugs. If you're tempted to write one, ask yourself: + +1. Is the `try`/`catch` even needed? (prefer removing it) +2. Should the error be handled explicitly? (recover, retry, rethrow) +3. At minimum, log it via `log.error("...", { err })` so failures are visible — never `catch {}` or `catch (e) {}` with no body. + +### Prefer single word naming + +Default to a single-word name for variables, parameters, and helper functions. Reach for a multi-word name only when a single word would be genuinely ambiguous in context — not just because the longer name "reads nicer". The rule is about meaning, not character count: don't introduce camelCase compounds like `inputPID`, `existingClient`, `connectTimeout`, or `workerPath` when `pid`, `client`, `timeout`, or `path` is already clear from the surrounding code. See the "Naming Enforcement" section above for the preferred vocabulary. ## Testing You MUST avoid using `mocks` as much as possible. Tests MUST test actual implementation, do not duplicate logic into a test. -## Fork Merge Process - -czcode is a fork of [kilocode](https://github.com/Kilo-Org/kilocode). - -**Very important**: when planning or coding, update shared files with kilocode as last resort. Everything in `packages/opencode/` is shared code from kilocode, except folders that contain `kilocode` in the name. Always look for ways to implement features in `packages/czcode-lakehouse/` or `packages/opencode/src/kilocode/` to minimize changes to shared code. +## Markdown Tables -### Minimizing Merge Conflicts +Do not pad markdown table cells for column alignment. Use the compact form with single-space-padded content cells and a minimal separator row: -We regularly merge upstream changes from kilocode. To minimize merge conflicts: +``` +| Command | What it runs | +|---|---| +| `kilo serve` | The prod CLI on `$PATH`. | +``` -1. **Prefer `kilocode` and `czcode` directories** — place czcode-specific code in: - - `packages/opencode/src/kilocode/` — kilocode-specific source (inherited) - - `packages/czcode-lakehouse/` — czcode Lakehouse plugin +Do **not** right-pad cells to line up columns: -2. **Minimize changes to shared files** — keep changes small and isolated. +``` +| Command | What it runs | +| ----------------------------- | ------------------------ | +| `kilo serve` | The prod CLI on `$PATH`. | +``` -3. **Use `czcode_change` markers** — when modifying shared code, mark changes with `czcode_change` comments. +Padding makes every content change rewrite the entire table, which blows up diffs on untouched rows. Markdown files are excluded from prettier (see `.prettierignore`) so running the formatter won't re-pad them, and `script/check-md-table-padding.ts` enforces the rule in CI. Run `bun run script/check-md-table-padding.ts --fix` to auto-rewrite padded tables. -4. **Avoid restructuring upstream code** — don't refactor opencode/kilocode code unless absolutely necessary. +## Commit Conventions -### czcode_change Markers +[Conventional Commits](https://www.conventionalcommits.org/) with scopes matching packages: `vscode`, `cli`, `agent-manager`, `sdk`, `ui`, `i18n`, `kilo-docs`, `gateway`, `telemetry`, `desktop`. Omit scope when spanning multiple packages. -czcode uses **two layers** of change markers corresponding to the fork chain: +## Changesets -| Marker | Purpose | Used when | -|---|---|---| -| `kilocode_change` | Marks kilocode changes relative to opencode | Merging opencode → kilocode (upstream of us) | -| `czcode_change` | Marks czcode changes relative to kilocode | Merging kilocode → czcode (our direct upstream) | +User-facing changes (features, fixes, breaking changes) require a changeset file for release notes. Prefer one concise changeset per PR, grouping related changes when possible. Run `bunx changeset add` or manually create `.changeset/.md`. Use `patch` for bug fixes, `minor` for new features, `major` for breaking changes. See `.changeset/README.md` for details. -**Rule: any code czcode modifies or adds that kilocode might also change needs a `czcode_change` marker.** This includes files inside `packages/opencode/src/kilocode/` — that directory is shared with kilocode upstream and will be overwritten during merges. +Changeset descriptions appear directly in release notes and are read by end users. Keep them concise and feature-oriented — describe **what changed from the user's perspective**, not implementation details. Write in imperative mood (e.g. "Support exporting conversations as markdown" not "Add a new export handler that serializes session messages to .md files"). -Mark czcode-specific changes with `czcode_change` comments. +## Pull Requests -**Single line:** +PR descriptions should explain **what** changed, **why** the change is needed, and the intent or constraints a reviewer cannot infer from the diff alone. Keep simple PRs brief, but give non-trivial changes enough context to stand on their own. Skip file-by-file inventories, test result summaries, and anything obvious from the code itself. -```typescript -const value = 42 // czcode_change -``` +## GitHub Issues -**Multi-line:** +When creating or managing GitHub issues for the VS Code extension or JetBrains plugin via `gh`, load `.kilo/skills/gh-issues/SKILL.md`. It covers templates, project boards (`VS Code Extension`, `Jetbrains Plugin`), title conventions, and the `gh auth refresh -s project` recovery path. -```typescript -// czcode_change start -const foo = 1 -const bar = 2 -// czcode_change end -``` +## Fork Merge Process -**New files:** +Kilo CLI is a fork of [opencode](https://github.com/anomalyco/opencode). -```typescript -// czcode_change - new file -``` +**Very important**: when planning or coding, update shared files with OpenCode as last resort! Everything is shared code from OpenCode, except folders that contain `kilo` in the name or have a parent directory that contains `kilo` in the name. Example of kilo specific folders: `packages/opencode/src/kilocode/` and `packages/kilo-docs/`. Always look for ways to implement your feature or fix in a way that minimizes changes to shared code. -**JSX/TSX:** +### Minimizing Merge Conflicts -```tsx -{/* czcode_change start */} - -{/* czcode_change end */} -``` +We regularly merge upstream changes from opencode. To minimize merge conflicts and keep the sync process smooth: -#### When markers are NOT needed +1. **Prefer `kilocode` directories** - Place Kilo-specific code in dedicated directories whenever possible: + - `packages/opencode/src/kilocode/` - Kilo-specific source code + - `packages/opencode/test/kilocode/` - Kilo-specific tests + - `packages/kilo-gateway/` - The Kilo Gateway package -Files in these paths are **entirely czcode additions** that do not exist in kilocode upstream, so they will never conflict during merges: +2. **Minimize changes to shared files** - When you must modify files that exist in upstream opencode, keep changes as small and isolated as possible. -- `packages/czcode-lakehouse/` — czcode Lakehouse plugin (czcode-only package) -- `packages/opencode/src/kilocode/plugins/czcode-*.tsx` — czcode TUI plugins (czcode-only files) -- `packages/opencode/src/kilocode/singclaw/` — SingClaw integration (czcode-only directory) -- `packages/opencode/src/agent/prompt/lh-*.txt` — Lakehouse agent prompts (czcode-only files) -- Any file with `czcode` in its filename +3. **Use `kilocode_change` markers** - When modifying shared code, mark your changes with `kilocode_change` comments so they can be easily identified during merges. + Do not use these markers in files within directories with kilo in the name -#### When markers ARE needed (even in kilocode directories) +4. **Avoid restructuring upstream code** - Don't refactor or reorganize code that comes from opencode unless absolutely necessary. -- `packages/opencode/src/kilocode/agent/index.ts` — shared with kilocode, czcode adds lh-* agents -- `packages/opencode/src/kilocode/config/config.ts` — shared with kilocode, czcode adds .czcode paths -- Any other file in `packages/opencode/src/kilocode/` that **already exists in kilocode upstream** -- `script/upstream/` files that czcode modifies (e.g. `transform-package-json.ts`) +5. **Mirror new config keys to the cloud schema** - When adding a `kilocode_change` key to `Config.Info` in `packages/opencode/src/config/config.ts`, also add the matching JSON Schema entry in `apps/web/src/app/config.json/extras.ts` in the [cloud repo](https://github.com/Kilo-Org/cloud). See [CLI Config Schema](packages/kilo-docs/pages/contributing/architecture/config-schema.md) for the step-by-step. -## Commit Conventions +The goal is to keep our diff from upstream as small as possible, making regular merges straightforward and reducing the risk of conflicts. -[Conventional Commits](https://www.conventionalcommits.org/) with scopes matching packages: `cli`, `lakehouse`, `sdk`, `upstream`, `tui`, `agents`, `config`, `singclaw`. Omit scope when spanning multiple packages. +### Git conflict style -## Release Process +`bun install` sets `merge.conflictStyle=zdiff3` repo-locally via `script/setup-git.ts` (wired into `postinstall`). Conflicts include the common ancestor between `|||||||` and `=======`, which is what `script/upstream/` and `mergiraf` rely on for structural resolution and what makes manual resolution on shared opencode files tractable. If you've overridden it in your user config, the repo-local setting takes precedence — don't override it back. -```bash -git push origin main -gh workflow run "Release" --ref main -f bump=patch # or minor/major -``` +### Kilocode Change Markers -Use the **"Release"** workflow, NOT "publish" (that's kilocode's upstream workflow). +When editing shared upstream files, mark Kilo-specific lines with `kilocode_change` comments so future merges can find them. The basic forms are: -## Post-Merge Smoke Test Checklist +- Single line: `const value = 42 // kilocode_change` +- Multi-line block: wrap with `// kilocode_change start` / `// kilocode_change end` +- New file in a shared path: `// kilocode_change - new file` at the top +- JSX/TSX: use `{/* kilocode_change */}` (and `{/* kilocode_change start */}` / `end`) -After merging upstream kilocode changes, test these before releasing: +Markers are NOT needed in paths that contain `kilocode` in the name (e.g. `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`) — these are entirely Kilo Code additions and won't conflict with upstream. -- [ ] `bun dev` starts without errors -- [ ] `bun test:local` builds and runs the compiled binary -- [ ] Default agent is `lh-analyst`, default model is `qwen3.5-plus` -- [ ] Basic conversation works (ask a question, get SQL response) -- [ ] `/cz_role` opens role picker, Tab cycles agents -- [ ] `/cz_sample` prompts for table name -- [ ] Copy to clipboard toast auto-dismisses (2 seconds) -- [ ] Sidebar shows Lakehouse connection info -- [ ] `czcode_change` annotation check passes +For decision rules on when to keep changes inline vs. extract Kilo logic, marker placement guidance, and verification commands, load `.kilo/skills/kilocode-merge-minimizer/SKILL.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b5f2569e4..69612910fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,6 @@ -# Contributing to czcode +# Contributing to Kilo CLI + +See [the Documentation for details on contributing](https://kilo.ai/docs/contributing). ## TL;DR @@ -8,6 +10,7 @@ There are lots of ways to contribute to the project: - **Documentation:** Improve existing docs or create new guides - **Bug Reports:** Report issues you encounter - **Feature Requests:** Suggest new features or improvements +- **Community Support:** Help other users in the community The Kilo Community is [on Discord](https://kilo.ai/discord). @@ -42,8 +45,8 @@ The Kilo Community is [on Discord](https://kilo.ai/discord). - Install dependencies and start the CLI from the repo root: ```bash - ~/.bun/bin/bun install - ~/.bun/bin/bun dev + bun install + bun dev ``` `bun dev` and `bun run dev` both run the local CLI. For the VS Code extension, use `bun run extension`. @@ -146,16 +149,50 @@ bun turbo test:ci --filter=@kilocode/kilo-jetbrains ### Running against a different directory -By default, `bun dev` runs czcode in the `packages/opencode` directory. To run it against a different directory: +By default, `bun dev` runs Kilo CLI in the `packages/opencode` directory. To run it against a different directory or repository: ```bash -~/.bun/bin/bun dev +bun dev ``` -To run czcode in the root of the repo itself: +To run Kilo CLI in the root of the repo itself: ```bash -~/.bun/bin/bun dev . +bun dev . +``` + +### Running Kilo CLI from any folder + +`bin/kilodev` is a self-locating launcher that runs this checkout from wherever you invoke it. Running it with no arguments launches the TUI pointed at the caller's directory; any arguments are forwarded to the CLI unchanged. + +One-shot install (recommended). From the repo root: + +```bash +./bin/kilodev dev-setup +``` + +This detects your shell, shows exactly what it will add, asks for confirmation, writes an idempotent block to your rc file, and saves a timestamped backup of the original. Re-running is safe — it only rewrites when the snippet has changed. + +Useful flags: + +- `--yes` — skip the confirmation prompt (good for CI/containers). +- `--print` — just print the snippet, don't touch any file (pipe-friendly). +- `--dry-run` — show what would change without writing. +- `--shell ` — override shell detection. +- `--rc ` — override the rc file. + +Manual alternatives (equivalent, no CLI invocation needed): + +- Unix: add `alias kilodev='/path/to/kilocode/bin/kilodev'` to `~/.zshrc` / `~/.bashrc`, or `fish_add_path /path/to/kilocode/bin`. +- Windows: add `C:\path\to\kilocode\bin` to PATH (System Environment Variables), or add `function kilodev { & "C:\path\to\kilocode\bin\kilodev.cmd" @args }` to `$PROFILE`. + +Then from anywhere: + +```bash +cd ~/some/project +kilodev # opens TUI with project = ~/some/project +kilodev dev-setup --print # prints the alias line (scripting) +kilodev run --dir "$PWD" "…" # subcommands pass through; use --dir for run/serve ``` ### Building a "local" binary @@ -166,24 +203,34 @@ To compile a standalone executable: ./packages/opencode/script/build.ts --single ``` -### Understanding bun dev vs czcode +Then run it with: + +```bash +./packages/opencode/dist/@kilocode/cli-/bin/kilo +``` + +Replace `` with your platform (e.g., `darwin-arm64`, `linux-x64`). -During development, `bun dev` is the local equivalent of the built `czcode` command: +### Understanding bun dev vs kilo + +During development, `bun dev` is the local equivalent of the built `kilo` command. Both run the same CLI interface: ```bash # Development (from project root) -~/.bun/bin/bun dev --help -~/.bun/bin/bun dev serve +bun dev --help # Show all available commands +bun dev serve # Start headless API server # Production -czcode --help -czcode serve +kilo --help # Show all available commands +kilo serve # Start headless API server ``` ### Testing with a local backend +To point the CLI at a local backend (e.g., a locally running Kilo API server on port 3000), set the `KILO_API_URL` environment variable: + ```bash -CZCODE_API_URL=http://localhost:3000 ~/.bun/bin/bun dev +KILO_API_URL=http://localhost:3000 bun dev ``` This redirects all gateway traffic (auth, model listing, provider routing, profile, etc.) to your local server. The default is `https://api.kilo.ai`. @@ -302,23 +349,3 @@ Maintainers may also close issues or PRs that disregard the contribution guide, - **Variables:** Prefer `const`. - **Naming:** Concise single-word identifiers when descriptive. - **Runtime APIs:** Use Bun helpers (e.g., `Bun.file()`). - -## czcode_change Annotation Rules - -When modifying files shared with the upstream (kilocode), annotate every change with a `czcode_change` marker. See [CLAUDE.md](CLAUDE.md) for the full annotation guide. - -## Pull Request Expectations - -- **UI Changes:** Include screenshots or videos (before/after). -- **Logic Changes:** Explain how you verified it works. -- **PR Titles:** Follow conventional commit standards (`feat:`, `fix:`, `docs:`, etc.). - -## PR Titles - -Use conventional commit style PR titles such as: - -- `feat: add execute_sql tool` -- `fix: correct Lakehouse connection timeout` -- `docs: update upstream sync instructions` -- `chore: bump kilocode to v7.x.y` -- `refactor: extract SQL classifier` diff --git a/README.md b/README.md index 2a264fedb7..4cb7120559 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ chmod +x czcode ### 2. 配置 AI 模型(必需) -czcode 需要 AI 模型才能运行。推荐使用阿里云 DashScope (Qwen 系列)。 +czcode 需要 AI 模型才能运行。 **方式 A: 配置文件(推荐)** diff --git a/artifacts/glm52-rise-video/out/flash-share.mp4 b/artifacts/glm52-rise-video/out/flash-share.mp4 new file mode 100644 index 0000000000..fa0a992016 --- /dev/null +++ b/artifacts/glm52-rise-video/out/flash-share.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e732068df8f53724058c5d4a3d49440dce1175814fde27b063c718c8be7c4f79 +size 333945 diff --git a/artifacts/glm52-rise-video/out/glm-52-broke-out.mp4 b/artifacts/glm52-rise-video/out/glm-52-broke-out.mp4 new file mode 100644 index 0000000000..684163d71c --- /dev/null +++ b/artifacts/glm52-rise-video/out/glm-52-broke-out.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd7dfb21da6ef6e81c68904aab90ebacd6ac5560b9886090920147df2e0737d8 +size 1591636 diff --git a/artifacts/glm52-rise-video/out/minimax-climb.mp4 b/artifacts/glm52-rise-video/out/minimax-climb.mp4 new file mode 100644 index 0000000000..de74d9ac0d --- /dev/null +++ b/artifacts/glm52-rise-video/out/minimax-climb.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44d0db412b4b4564f6f6882e27f54a0b6e9c540a930ee200effe2671d1f7de75 +size 349608 diff --git a/artifacts/glm52-rise-video/out/novel-1984.mp4 b/artifacts/glm52-rise-video/out/novel-1984.mp4 new file mode 100644 index 0000000000..b74910859c --- /dev/null +++ b/artifacts/glm52-rise-video/out/novel-1984.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4711b5fc12c048a153cf43795c568599a08115c8292326460291aae475ad2f1f +size 2569837 diff --git a/artifacts/glm52-rise-video/out/nz-sheep.mp4 b/artifacts/glm52-rise-video/out/nz-sheep.mp4 new file mode 100644 index 0000000000..d1df94d472 --- /dev/null +++ b/artifacts/glm52-rise-video/out/nz-sheep.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fec0900bb1250e45b75f60c60cd830f02d0b7eaf8fc3cfcad7772f44f5f55208 +size 2788751 diff --git a/docs/jetbrains-vscode-settings-parity.md b/docs/jetbrains-vscode-settings-parity.md index 5388c36c9b..488cd5d173 100644 --- a/docs/jetbrains-vscode-settings-parity.md +++ b/docs/jetbrains-vscode-settings-parity.md @@ -66,7 +66,7 @@ existing RPC, no config plumbing. | Setting | Config key | Extra work | |---|---|---| -| Auto-collapse reasoning | `auto_collapse_reasoning` | Reasoning-card default collapse | +| Reasoning blocks | `reasoning_display` (expanded/preview/headline); legacy `auto_collapse_reasoning` maps to preview | Reasoning-card default state per mode | | Terminal command display | `terminal_command_display` (expanded/collapsed) | Tool-card default state | | Code edit display | `code_edit_display` (expanded/collapsed) | Edit-card default state | diff --git a/package.json b/package.json index dcd088302f..86fc3e7129 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "packageManager": "bun@1.3.14", "scripts": { "dev": "KILO_CLIENT=cli bun run --cwd packages/opencode --conditions=node src/index.ts", - "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", "check:architecture": "bun run script/check-architecture.ts", @@ -51,7 +50,6 @@ "@cloudflare/workers-types": "4.20251008.0", "@openauthjs/openauth": "0.0.0-20250322224806", "@pierre/diffs": "1.2.10", - "opentui-spinner": "0.0.7", "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", "diff": "8.0.4", @@ -178,10 +176,11 @@ "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", "pacote@21.5.1": "patches/pacote@21.5.1.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", - "solid-js@1.9.12": "patches/solid-js@1.9.12.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", + "solid-js@1.9.12": "patches/solid-js@1.9.12.patch", + "bun-pty@0.4.8": "patches/bun-pty@0.4.8.patch", "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch" }, - "version": "7.2.49", + "version": "7.7.3", "peerDependencies": {} } diff --git a/packages/core/schema.json b/packages/core/schema.json index 4443db4f57..298023e859 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "38a74186-5907-4662-9e14-e059300a8b4e", + "id": "8e919778-bf29-4e1b-a106-88bc9d8b8b21", "prevIds": [ - "fcf518b9-c8bc-4ee5-8e68-2608bffcef43" + "38a74186-5907-4662-9e14-e059300a8b4e" ], "ddl": [ { @@ -2209,6 +2209,28 @@ "entityType": "indexes", "table": "message" }, + { + "columns": [ + { + "value": "id", + "isExpression": false + }, + { + "value": "json_extract(\"data\", '$.role')", + "isExpression": true + }, + { + "value": "coalesce(json_extract(\"data\", '$.parentID'), '')", + "isExpression": true + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "recall_message_role_idx", + "entityType": "indexes", + "table": "message" + }, { "columns": [ { @@ -2482,3 +2504,4 @@ ], "renames": [] } + diff --git a/packages/core/script/kilocode/migration.ts b/packages/core/script/kilocode/migration.ts index 40750df1d6..110a7ae627 100644 --- a/packages/core/script/kilocode/migration.ts +++ b/packages/core/script/kilocode/migration.ts @@ -7,7 +7,8 @@ export function file(name: string, value: string) { } export function block(name: string | undefined, source: string, value: string) { - return (name !== undefined && board(name)) || /kilo_board(?:_message)?|part_session_step_finish_idx/.test(source) + return (name !== undefined && board(name)) || + /kilo_board(?:_message)?|part_session_step_finish_idx|recall_(?:part_search|message_role)_idx/.test(source) ? `// kilocode_change start\n${value}\n// kilocode_change end` : value } diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index f961caf4a1..9af0e29357 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -283,6 +283,11 @@ export default { yield* tx.run( `CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`, ) + // kilocode_change start + yield* tx.run( + `CREATE INDEX \`recall_message_role_idx\` ON \`message\` (\`id\`,json_extract("data", '$.role'),coalesce(json_extract("data", '$.parentID'), ''));`, + ) + // kilocode_change end yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`) yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`) // kilocode_change start @@ -290,9 +295,11 @@ export default { `CREATE INDEX \`part_session_step_finish_idx\` ON \`part\` (\`session_id\`) WHERE json_valid("part"."data") AND json_extract("part"."data", '$.type') = 'step-finish';`, ) // kilocode_change end + // kilocode_change start yield* tx.run( `CREATE INDEX \`recall_part_search_idx\` ON \`part\` (\`session_id\`,\`id\`,\`message_id\`,json_extract("data", '$.type'),CASE WHEN json_extract("data", '$.type') = 'text' THEN coalesce(json_extract("data", '$.text'), '') WHEN json_extract("data", '$.type') = 'file' THEN trim(coalesce(json_extract("data", '$.filename'), '') || ' ' || CASE WHEN coalesce(json_extract("data", '$.url'), '') NOT LIKE 'data:%' THEN coalesce(json_extract("data", '$.url'), '') ELSE '' END || ' ' || coalesce(json_extract("data", '$.source.path'), '') || ' ' || coalesce(json_extract("data", '$.source.name'), '') || ' ' || CASE WHEN coalesce(json_extract("data", '$.source.uri'), '') NOT LIKE 'data:%' THEN coalesce(json_extract("data", '$.source.uri'), '') ELSE '' END || ' ' || coalesce(json_extract("data", '$.source.clientName'), '')) ELSE coalesce(json_extract("data", '$.state.error'), '') END) WHERE json_valid("part"."data") AND ((json_extract("part"."data", '$.type') = 'text' AND coalesce(json_extract("part"."data", '$.synthetic'), 0) = 0 AND coalesce(json_extract("part"."data", '$.ignored'), 0) = 0) OR json_extract("part"."data", '$.type') = 'file' OR (json_extract("part"."data", '$.type') = 'tool' AND json_extract("part"."data", '$.state.status') = 'error'));`, ) + // kilocode_change end yield* tx.run( `CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`, ) diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 619a8bc196..10861364ab 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -1,4 +1,5 @@ import path from "path" +import fs from "fs/promises" import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" import os from "os" import { Context, Effect, Layer } from "effect" @@ -8,7 +9,7 @@ import { ensureRealDir, resolveState } from "./kilocode/global" // kilocode_chan import { Flag } from "./flag/flag" import { makeGlobalNode } from "./effect/app-node" -const app = "czcode" // czcode_change +const app = "kilo" // kilocode_change // kilocode_change start // Defensively strip newline characters from the resolved XDG paths. // If `$HOME` (or any `$XDG_*_HOME` override) has a trailing newline in @@ -27,9 +28,8 @@ const state = await resolveState(preferred, process.env.XDG_STATE_HOME ? undefin const tmp = path.join(os.tmpdir(), app) const paths = { - // Allow override via CZCODE_TEST_HOME for test isolation get home() { - return (process.env.CZCODE_TEST_HOME || process.env.KILO_TEST_HOME || os.homedir()).trim() // czcode_change — defensive trim, see above + return (process.env.KILO_TEST_HOME ?? os.homedir()).trim() // kilocode_change — defensive trim, see above }, data, bin: path.join(cache, "bin"), diff --git a/packages/core/src/kilocode/provider-usage.ts b/packages/core/src/kilocode/provider-usage.ts index a27f4a3bd6..562c282629 100644 --- a/packages/core/src/kilocode/provider-usage.ts +++ b/packages/core/src/kilocode/provider-usage.ts @@ -9,13 +9,15 @@ import { Integration } from "../integration" import { PluginV2 } from "../plugin" import { ProviderV2 } from "../provider" import * as Cloud from "./provider-usage/cloud" +import * as Codex from "./provider-usage/codex" import { bindings, direct, type Candidate } from "./provider-usage/minimax/usage" const successTtl = 60_000 const errorTtl = 10_000 const readyPlugin = PluginV2.ID.make("config-provider") -interface AdapterContext { +export interface AdapterContext { + providers: readonly ProviderV2.Info[] candidates: readonly Candidate[] failedCandidates: readonly Candidate["providerID"][] cloud: (() => Promise) | undefined @@ -34,9 +36,10 @@ interface AdapterResult { items: ReadonlyArray } -interface Adapter { +export interface Adapter { cachePrefixes: readonly string[] cloudScoped?: boolean + valid?: () => boolean run(ctx: AdapterContext): Promise } @@ -80,8 +83,6 @@ const minimax: Adapter = { }, } -const registry: readonly Adapter[] = [managed, minimax] - export class ServiceError extends Schema.TaggedErrorClass()("ProviderUsageServiceError", { message: Schema.String, }) {} @@ -122,6 +123,7 @@ function scopeCloudCache(state: State, token: string | undefined) { function stale(next: Contract.UsageSnapshot, previous: Contract.UsageSnapshot | undefined) { if (next.fetchState !== "unavailable" && next.fetchState !== "error") return next + if (next.error?.retryable === false) return next if (!previous || (previous.fetchState !== "ready" && previous.fetchState !== "stale")) return next return { ...previous, @@ -311,6 +313,7 @@ const inputs = Effect.fn("ProviderUsage.inputs")(function* ( const token = kilo.ok && kilo.value?.type === "oauth" && !organization && kilo.value.access ? kilo.value.access : undefined return { + providers, candidates: candidates.filter((item): item is Candidate => item !== undefined), failedCandidates, token, @@ -325,12 +328,14 @@ function makeService( ready: Effect.Effect, ) { const state: State = { sources: new Map(), cloud: { expires: 0 } } + const codex = Codex.create(integrations) const evaluate = Effect.fn("ProviderUsage.evaluate")(function* (force: boolean) { yield* ready const current = yield* inputs(catalog, integrations) const cloudIdentity = current.cloudReliable ? scopeCloudCache(state, current.token) : state.cloudIdentity const ctx: AdapterContext = { + providers: current.providers, candidates: current.candidates, failedCandidates: current.failedCandidates, cloud: @@ -347,19 +352,23 @@ function makeService( preserve: (prefix, identity) => preserve(state, prefix, identity), prune: (prefix, keep) => prune(state, prefix, keep), } + const registry: readonly Adapter[] = [managed, minimax, yield* codex(ctx)] const results = yield* Effect.promise(() => Promise.all( registry.map((adapter) => // Adapters are expected to be total (they absorb their own failures into // unavailable/stale snapshots). This catch is the containment boundary so a // faulty future adapter degrades to stale output instead of failing the endpoint. - adapter.run(ctx).catch( - (): AdapterResult => ({ - items: adapter.cachePrefixes.flatMap((prefix) => - ctx.preserve(prefix, adapter.cloudScoped ? ctx.cloudIdentity : undefined), - ), - }), - ), + adapter + .run(ctx) + .catch( + (): AdapterResult => ({ + items: adapter.cachePrefixes.flatMap((prefix) => + ctx.preserve(prefix, adapter.cloudScoped ? ctx.cloudIdentity : undefined), + ), + }), + ) + .then((result) => ({ ...result, valid: adapter.valid })), ), ), ) @@ -367,7 +376,8 @@ function makeService( (value): value is string => value !== undefined, ) return { - items: results.flatMap((result) => result.items), + // An adapter can be invalidated while a slower sibling is still loading. + items: results.filter((result) => result.valid?.() !== false).flatMap((result) => result.items), generatedAt: stamps.toSorted().at(-1) ?? new Date().toISOString(), } satisfies Contract.Info }) diff --git a/packages/core/src/kilocode/provider-usage/codex.ts b/packages/core/src/kilocode/provider-usage/codex.ts new file mode 100644 index 0000000000..31cbaf3fed --- /dev/null +++ b/packages/core/src/kilocode/provider-usage/codex.ts @@ -0,0 +1,360 @@ +import type { ProviderUsage } from "@opencode-ai/schema/kilocode/provider-usage" +import { Effect } from "effect" +import { createHash } from "node:crypto" +import { Integration } from "../../integration" +import { ProviderV2 } from "../../provider" +import type { Adapter, AdapterContext } from "../provider-usage" + +const url = "https://chatgpt.com/backend-api/wham/usage" +const manage = "https://chatgpt.com/codex/settings/usage" +const limit = 64 * 1024 +const timeout = 5_000 +const maximum = 8_640_000_000_000_000 + +const plans: Record = { + plus: "ChatGPT Plus", + pro: "ChatGPT Pro", + prolite: "ChatGPT Pro Lite", + business: "ChatGPT Enterprise", + self_serve_business_prolite: "ChatGPT Business Premium", + self_serve_business_usage_based: "ChatGPT Business", + ent26: "ChatGPT Enterprise", + enterprise_cbp_automation: "ChatGPT Enterprise (Automation)", + enterprise_cbp_usage_based: "ChatGPT Enterprise", + enterprise: "ChatGPT Enterprise", + edu: "ChatGPT Edu", + education: "ChatGPT Edu", + edu_plus: "ChatGPT Edu Plus", + edu_pro: "ChatGPT Edu Pro", + team: "ChatGPT Business", + free: "ChatGPT Free", + go: "ChatGPT Go", +} + +interface Candidate { + label: string + access: string + account?: string +} + +const discover = Effect.fn("ProviderUsage.Codex.discover")(function* ( + provider: ProviderV2.Info | undefined, + integrations: Integration.Interface, +) { + if (!provider || provider.disabled) return { status: "absent" as const } + const connection = yield* integrations.connection.active(provider.integrationID ?? Integration.ID.make(provider.id)) + if (!connection) return { status: "absent" as const } + const marker = createHash("sha256") + .update(`${connection.type}:${connection.type === "credential" ? connection.id : connection.name}`) + .digest("hex") + const resolved = yield* integrations.connection.resolve(connection).pipe( + Effect.map((value) => ({ ok: true as const, value })), + Effect.catch(() => Effect.succeed({ ok: false as const })), + ) + if (!resolved.ok) return { status: "failed" as const, connection: marker } + if (resolved.value?.type !== "oauth" || !resolved.value.access) return { status: "absent" as const } + const raw = resolved.value.metadata?.accountID + const account = typeof raw === "string" && /^[A-Za-z0-9._-]{1,256}$/.test(raw) ? raw : undefined + return { + status: "ready" as const, + connection: marker, + identity: createHash("sha256") + .update( + JSON.stringify([marker, typeof raw === "string" ? raw : "", resolved.value.access, resolved.value.refresh]), + ) + .digest("hex"), + candidate: { + label: provider.name, + access: resolved.value.access, + ...(account ? { account } : {}), + }, + } +}) + +export function create(integrations: Integration.Interface) { + let state: { connection: string; identity: string } | undefined + return Effect.fn("ProviderUsage.Codex.prepare")(function* (ctx: AdapterContext) { + const current = yield* discover( + ctx.providers.find((provider) => provider.id === ProviderV2.ID.openai), + integrations, + ) + const retained = + (current.status === "failed" && state?.connection === current.connection) || + (current.status === "ready" && state?.connection === current.connection && state.identity === current.identity) + if (!retained) { + state = current.status === "ready" ? { connection: current.connection, identity: current.identity } : undefined + ctx.prune("codex-chatgpt", []) + } + const identity = state?.identity + const valid = () => identity !== undefined && state?.identity === identity + return { + cachePrefixes: ["codex-chatgpt"], + valid, + async run(ctx) { + if (!valid() || current.status === "absent") return { items: [] } + if (current.status === "failed") return { items: ctx.preserve("codex-chatgpt", identity) } + const item = await ctx.source("codex-chatgpt", () => load(current.candidate, ctx.fetch), current.identity) + return { items: [item] } + }, + } satisfies Adapter + }) +} + +interface Window { + used: number + duration?: number + reset?: number + after?: number +} + +interface Rate { + primary?: Window + secondary?: Window +} + +interface Native { + plan?: string + rate?: Rate + additional: { id: string; name: string; rate: Rate }[] +} + +class Failure extends Error { + constructor(readonly code: "network" | "auth" | "http" | "size" | "invalid") { + super(code === "auth" ? "ChatGPT authentication is unavailable." : "Codex usage is unavailable.") + } +} + +function object(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input) +} + +function number(input: unknown) { + return typeof input === "number" && Number.isFinite(input) ? input : undefined +} + +function window(input: unknown): Window | undefined { + if (!object(input)) return undefined + const used = number(input.used_percent) + if (used === undefined) return undefined + return { + used, + duration: number(input.limit_window_seconds), + reset: number(input.reset_at), + after: number(input.reset_after_seconds), + } +} + +function rate(input: unknown): Rate | undefined { + if (!object(input)) return undefined + return { + primary: window(input.primary_window), + secondary: window(input.secondary_window), + } +} + +export function decode(input: unknown): Native { + if (!object(input) || typeof input.plan_type !== "string" || !input.plan_type.trim()) throw new Failure("invalid") + if (input.additional_rate_limits != null && !Array.isArray(input.additional_rate_limits)) throw new Failure("invalid") + const entries = input.additional_rate_limits ?? [] + const main = rate(input.rate_limit) + const additional = entries.flatMap((item) => { + if (!object(item)) return [] + const limit = rate(item.rate_limit) + if (!limit) return [] + const feature = typeof item.metered_feature === "string" ? item.metered_feature.trim() : "" + const name = + typeof item.limit_name === "string" && item.limit_name.trim() + ? item.limit_name.trim() + : feature || "Additional quota" + return [{ id: feature || name, name, rate: limit }] + }) + const supplied = [input.rate_limit, ...entries.map((item) => (object(item) ? item.rate_limit : item))] + if ( + !main?.primary && + !main?.secondary && + !additional.some((item) => item.rate.primary || item.rate.secondary) && + supplied.some( + (value) => value != null && (!object(value) || value.primary_window != null || value.secondary_window != null), + ) + ) + throw new Failure("invalid") + return { + plan: input.plan_type, + rate: main, + additional, + } +} + +async function body(response: Response) { + const declared = Number(response.headers.get("content-length")) + if (Number.isFinite(declared) && declared > limit) { + response.body?.cancel().catch(() => undefined) + throw new Failure("size") + } + if (!response.body) { + const value = await response.arrayBuffer() + if (value.byteLength > limit) throw new Failure("size") + return new TextDecoder().decode(value) + } + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + while (true) { + const chunk = await reader.read() + if (chunk.done) break + if (!chunk.value) continue + size += chunk.value.byteLength + if (size > limit) { + await reader.cancel().catch(() => undefined) + throw new Failure("size") + } + chunks.push(chunk.value) + } + const value = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + value.set(chunk, offset) + offset += chunk.byteLength + } + return new TextDecoder().decode(value) +} + +export async function query(candidate: Candidate, fetcher: typeof fetch = fetch): Promise { + const headers: Record = { + Accept: "application/json", + Authorization: `Bearer ${candidate.access}`, + } + if (candidate.account) headers["ChatGPT-Account-Id"] = candidate.account + const response = await fetcher(url, { + method: "GET", + headers, + cache: "no-store", + redirect: "error", + signal: AbortSignal.timeout(timeout), + }).catch(() => { + throw new Failure("network") + }) + if (!response.ok) { + response.body?.cancel().catch(() => undefined) + throw new Failure(response.status === 401 || response.status === 403 ? "auth" : "http") + } + const text = await body(response) + try { + return decode(JSON.parse(text)) + } catch { + throw new Failure("invalid") + } +} + +function reset(value: Window, now: number) { + const direct = value.reset === undefined ? undefined : value.reset * 1000 + if (direct !== undefined && direct > 0 && direct <= maximum) return new Date(direct).toISOString() + const offset = value.after === undefined ? undefined : value.after * 1000 + const relative = offset === undefined ? undefined : now + offset + if ( + relative !== undefined && + offset !== undefined && + offset > 0 && + relative <= maximum && + Number.isSafeInteger(relative) + ) { + return new Date(relative).toISOString() + } + return undefined +} + +function period(duration: number): ProviderUsage.UsagePeriod | undefined { + for (const [unit, seconds] of [ + ["week", 604_800], + ["day", 86_400], + ["hour", 3_600], + ] as const) { + if (duration % seconds === 0) return { unit, value: duration / seconds } + } + return undefined +} + +function windows(id: string, name: string, rate: Rate | undefined, now: number) { + if (!rate) return [] + return ( + [ + ["primary", rate.primary], + ["secondary", rate.secondary], + ] as const + ).flatMap(([slot, value]) => { + if (!value) return [] + const percent = Math.min(100, Math.max(0, value.used)) + const duration = + value.duration !== undefined && value.duration > 0 && Number.isSafeInteger(value.duration * 1000) + ? value.duration + : undefined + return [ + { + id: `${id}-${slot}`, + resource: name, + unit: "percent", + orientation: "used_percent", + used: percent, + remaining: 100 - percent, + limit: 100, + durationMs: duration === undefined ? undefined : duration * 1000, + period: duration === undefined ? undefined : period(duration), + resetAt: reset(value, now), + state: percent === 100 ? "exhausted" : "active", + } satisfies ProviderUsage.UsageWindow, + ] + }) +} + +export function normalize(native: Native, label = "OpenAI"): ProviderUsage.UsageSnapshot { + const now = Date.now() + const seen = new Map() + const main = windows("codex", "Codex", native.rate, now) + const additional = native.additional.flatMap((item) => { + const count = seen.get(item.id) ?? 0 + seen.set(item.id, count + 1) + return windows(JSON.stringify(["additional", item.id, count]), item.name, item.rate, now) + }) + const plan = plans[native.plan?.toLowerCase() ?? ""] + return { + id: "codex-chatgpt", + providerID: "openai", + sourceKind: "direct", + providerLabel: label, + planLabel: typeof plan === "string" ? plan : "ChatGPT Codex", + sourceLabel: "ChatGPT OAuth", + fetchState: "ready", + planState: "active", + routingState: "not_applicable", + fetchedAt: new Date(now).toISOString(), + managementUrl: manage, + windows: [...main, ...additional], + } +} + +function unavailable(label: string, auth: boolean): ProviderUsage.UsageSnapshot { + return { + id: "codex-chatgpt", + providerID: "openai", + sourceKind: "direct", + providerLabel: label, + planLabel: "ChatGPT Codex", + sourceLabel: "ChatGPT OAuth", + fetchState: "unavailable", + planState: "unknown", + routingState: "not_applicable", + managementUrl: manage, + windows: [], + error: { + code: auth ? "codex_auth_unavailable" : "codex_usage_unavailable", + message: auth ? "Reconnect ChatGPT to view Codex usage." : "Usage unavailable.", + retryable: !auth, + }, + } +} + +export function load(candidate: Candidate, fetcher: typeof fetch = fetch) { + return query(candidate, fetcher) + .then((native) => normalize(native, candidate.label)) + .catch((error) => unavailable(candidate.label, error instanceof Failure && error.code === "auth")) +} diff --git a/packages/core/src/kilocode/pty/latch.ts b/packages/core/src/kilocode/pty/latch.ts new file mode 100644 index 0000000000..1152e1214e --- /dev/null +++ b/packages/core/src/kilocode/pty/latch.ts @@ -0,0 +1,40 @@ +import type { Disp, Exit, Proc } from "../../pty/pty" + +// bun-pty emits data and exit from its read loop and drops events that fire before a listener +// is attached. A short-lived child can exit in the gap between spawn and the Pty service +// registering its listeners, so buffer early events and replay them once a listener attaches. +// Replay runs in a microtask so the caller finishes wiring the session before it observes them. +function attach( + early: Disp, + buffer: T[], + subscribe: (listener: (event: T) => void) => Disp, + listener: (event: T) => void, +): Disp { + early.dispose() + const disp = subscribe(listener) + const state = { live: true } + queueMicrotask(() => { + if (!state.live) return + for (const event of buffer.splice(0)) listener(event) + }) + return { + dispose() { + state.live = false + disp.dispose() + }, + } +} + +export function latch(proc: Proc): Proc { + const data: string[] = [] + const exit: Exit[] = [] + const early = { + data: proc.onData((chunk) => data.push(chunk)), + exit: proc.onExit((event) => exit.push(event)), + } + return { + ...proc, + onData: (listener) => attach(early.data, data, (fn) => proc.onData(fn), listener), + onExit: (listener) => attach(early.exit, exit, (fn) => proc.onExit(fn), listener), + } +} diff --git a/packages/core/src/kilocode/session/recall-message-index.ts b/packages/core/src/kilocode/session/recall-message-index.ts new file mode 100644 index 0000000000..e391b4f69c --- /dev/null +++ b/packages/core/src/kilocode/session/recall-message-index.ts @@ -0,0 +1,17 @@ +import { sql } from "drizzle-orm" +import { index, type AnySQLiteColumn } from "drizzle-orm/sqlite-core" + +// Covering index so recall search can resolve message roles without reading message rows. +export namespace RecallMessageIndex { + export const name = "recall_message_role_idx" + + export const createSql = `CREATE INDEX IF NOT EXISTS \`${name}\` ON \`message\` (\`id\`,json_extract("data", '$.role'),coalesce(json_extract("data", '$.parentID'), ''));` + + export function make(table: { id: AnySQLiteColumn; data: AnySQLiteColumn }) { + return index(name).on( + table.id, + sql`json_extract(${table.data}, '$.role')`, + sql`coalesce(json_extract(${table.data}, '$.parentID'), '')`, + ) + } +} diff --git a/packages/core/src/pty/pty.bun.ts b/packages/core/src/pty/pty.bun.ts index 1f8ce8e454..d67b6918e4 100644 --- a/packages/core/src/pty/pty.bun.ts +++ b/packages/core/src/pty/pty.bun.ts @@ -1,11 +1,14 @@ import { spawn as create } from "bun-pty" +import { latch } from "../kilocode/pty/latch" // kilocode_change import type { Opts, Proc } from "./pty" export type { Disp, Exit, Opts, Proc } from "./pty" export function spawn(file: string, args: string[], opts: Opts): Proc { const pty = create(file, args, opts) - return { + // kilocode_change start - bun-pty drops events emitted before listeners attach + return latch({ + // kilocode_change end pid: pty.pid, onData(listener) { return pty.onData(listener) @@ -22,5 +25,5 @@ export function spawn(file: string, args: string[], opts: Opts): Proc { kill(signal) { pty.kill(signal) }, - } + }) // kilocode_change } diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index b7e6451c02..5346fa6a06 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -14,6 +14,7 @@ import { Timestamps } from "../database/schema.sql" import type { SystemContext } from "../system-context/index" import type { Revert } from "@opencode-ai/schema/revert" import { RecallPartIndex } from "../kilocode/session/recall-part-index" // kilocode_change +import { RecallMessageIndex } from "../kilocode/session/recall-message-index" // kilocode_change import { sql } from "drizzle-orm" // kilocode_change type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> @@ -80,7 +81,12 @@ export const MessageTable = sqliteTable( ...Timestamps, data: text({ mode: "json" }).notNull().$type(), }, - (table) => [index("message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id)], + // kilocode_change start + (table) => [ + index("message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id), + RecallMessageIndex.make(table), + ], + // kilocode_change end ) export const PartTable = sqliteTable( diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 1e9f96aba7..38a37ee8dd 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -107,7 +107,14 @@ export const Info = Schema.Struct({ description: "Enable remote control of sessions via Kilo Cloud. Equivalent to running /remote on startup.", }), auto_collapse_reasoning: Schema.optional(Schema.Boolean).annotate({ - description: "Automatically collapse reasoning blocks after the agent finishes writing them", + description: + "@deprecated Use 'reasoning_display' field instead. Automatically collapse reasoning blocks after the agent finishes writing them", + }), + reasoning_display: Schema.optional(Schema.Literals(["expanded", "preview", "headline"])).annotate({ + description: "Controls how reasoning blocks are displayed in the VS Code chat UI", + }), + shared_agent_board: Schema.optional(Schema.Boolean).annotate({ + description: "Share a board between a main session and its task subagents, including nested subagents", }), indexing: Schema.optional(IndexingRef).annotate({ description: "Codebase indexing configuration" }), console: Schema.optional( @@ -310,6 +317,13 @@ export const Info = Schema.Struct({ speech_to_text_model: Schema.optional(Schema.String).annotate({ description: "Speech-to-text transcription model ID to use for voice input", }), + speech_to_text_base_url: Schema.optional(Schema.String).annotate({ + description: + "Base URL of an OpenAI-compatible transcription API to use instead of the Kilo Gateway, for example https://api.openai.com/v1", + }), + speech_to_text_api_key: Schema.optional(Schema.String).annotate({ + description: "API key sent as a bearer token to the custom speech-to-text base URL", + }), openTelemetry: Schema.Boolean.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(true))).annotate({ description: "Enable telemetry. Set to false to opt-out.", }), diff --git a/packages/core/src/v1/config/provider.ts b/packages/core/src/v1/config/provider.ts index e34856b257..675300481c 100644 --- a/packages/core/src/v1/config/provider.ts +++ b/packages/core/src/v1/config/provider.ts @@ -74,6 +74,7 @@ export const Model = Schema.Struct({ Schema.Record( Schema.String, Schema.NullOr( + // kilocode_change - allow null values so removed variants can be deleted via stripNulls on save Schema.StructWithRest( Schema.Struct({ disabled: Schema.optional(Schema.Boolean).annotate({ description: "Disable this variant for the model" }), @@ -128,6 +129,6 @@ export const Info = Schema.Struct({ [Schema.Record(Schema.String, Schema.Any)], ), ), - models: Schema.optional(Schema.Record(Schema.String, Schema.NullOr(Model))), + models: Schema.optional(Schema.Record(Schema.String, Schema.NullOr(Model))), // kilocode_change - allow null values so removed models can be deleted via stripNulls on save }).annotate({ identifier: "ProviderConfig" }) export type Info = Schema.Schema.Type diff --git a/packages/core/test/kilocode-provider-usage-codex.test.ts b/packages/core/test/kilocode-provider-usage-codex.test.ts new file mode 100644 index 0000000000..3ef35fbbc3 --- /dev/null +++ b/packages/core/test/kilocode-provider-usage-codex.test.ts @@ -0,0 +1,711 @@ +import { describe, expect, test } from "bun:test" +import { Deferred, Effect, Fiber, Layer } from "effect" +import path from "node:path" +import { Catalog } from "../src/catalog" +import { Credential } from "../src/credential" +import { Database } from "../src/database/database" +import { AppNodeBuilder } from "../src/effect/app-node-builder" +import { LayerNode } from "../src/effect/layer-node" +import { Global } from "../src/global" +import { Integration } from "../src/integration" +import { ProviderUsage } from "../src/kilocode/provider-usage" +import { decode, load, normalize } from "../src/kilocode/provider-usage/codex" +import { Location } from "../src/location" +import { PluginV2 } from "../src/plugin" +import { ProviderV2 } from "../src/provider" +import { AbsolutePath } from "../src/schema" +import { location } from "./fixture/location" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const openai = Integration.ID.make("openai") +const minimax = Integration.ID.make("minimax-coding-plan") +const method = Integration.MethodID.make("chatgpt-browser") +const it = testEffect(Layer.empty) + +type RequestHandler = (input: string | URL | Request, init?: RequestInit) => Promise + +type Fixture = { + usage: ProviderUsage.Interface + catalog: Catalog.Interface + integrations: Integration.Interface + credentials: Credential.Interface + requests: Array<{ url: string; init: RequestInit }> + refreshes: { count: number } +} + +const window = (used: number, seconds = 18_000) => ({ + used_percent: used, + limit_window_seconds: seconds, + reset_after_seconds: seconds, + reset_at: Math.floor(Date.now() / 1000) + seconds, +}) + +const payload = (overrides: Record = {}) => ({ + plan_type: "plus", + rate_limit: { + allowed: true, + limit_reached: false, + primary_window: window(20), + secondary_window: window(35, 604_800), + }, + additional_rate_limits: [], + ...overrides, +}) + +const native = (remaining: number) => + Response.json({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "general", + current_interval_remaining_percent: remaining, + current_interval_status: 1, + }, + ], + }) + +const fixture = ( + handler: RequestHandler, + body: (value: Fixture) => Effect.Effect, + opts: { minimax?: boolean; failure?: () => boolean } = {}, +) => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => { + const requests: Fixture["requests"] = [] + const refreshes = { count: 0 } + const transport = Layer.succeed(ProviderUsage.Transport, { + fetch: Object.assign( + (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + requests.push({ url, init: init ?? {} }) + return handler(input, init) + }, + { preconnect: fetch.preconnect }, + ), + plans: async () => [], + byok: async () => [], + usage: async () => { + throw new Error("Unexpected managed usage request") + }, + }) + const layer = AppNodeBuilder.build( + LayerNode.group([ProviderUsage.node, Catalog.node, Integration.node, Credential.node, PluginV2.node]), + [ + [ + Global.node, + Global.layerWith({ + home: dir.path, + data: dir.path, + cache: dir.path, + config: dir.path, + state: dir.path, + tmp: dir.path, + bin: dir.path, + log: dir.path, + repos: dir.path, + }), + ], + [Database.node, Database.layerFromPath(path.join(dir.path, "provider-usage.sqlite"))], + [ + Location.node, + Layer.succeed( + Location.Service, + Location.Service.of(location(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))), + ), + ], + [ProviderUsage.transportNode, transport], + ], + ) + + return Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const catalog = yield* Catalog.Service + const integrations = yield* Integration.Service + const credentials = yield* Credential.Service + const usage = yield* ProviderUsage.Service + + yield* plugins.add(PluginV2.ID.make("config-provider"), (host) => + Effect.gen(function* () { + yield* host.integration.transform((draft) => { + draft.method.update({ + integrationID: openai, + method: { id: method, type: "oauth", label: "ChatGPT" }, + authorize: () => Effect.die("Unexpected OAuth authorization"), + refresh: (value) => + Effect.suspend(() => { + refreshes.count++ + if (opts.failure?.()) return Effect.fail(new Error("private OAuth refresh failure")) + return Effect.succeed( + Credential.OAuth.make({ + ...value, + methodID: method, + access: "refreshed-access-token", + refresh: "rotated-refresh-token", + expires: Date.now() + 3_600_000, + }), + ) + }), + }) + draft.method.update({ + integrationID: openai, + method: { type: "key", label: "API key" }, + }) + if (opts.minimax) + draft.method.update({ + integrationID: minimax, + method: { type: "key", label: "MiniMax API key" }, + }) + }) + yield* host.catalog.transform((draft) => { + draft.provider.update(openai, (provider) => { + provider.name = "OpenAI" + provider.integrationID = openai + }) + if (opts.minimax) + draft.provider.update(minimax, (provider) => { + provider.name = "MiniMax Global" + provider.integrationID = minimax + }) + }) + }), + ) + + return yield* body({ usage, catalog, integrations, credentials, requests, refreshes }) + }).pipe(Effect.provide(layer)) + }), + ) + +const connect = Effect.fn("CodexProviderUsageTest.connect")(function* ( + credentials: Credential.Interface, + input: { access?: string; account?: string; expires?: number } = {}, +) { + return yield* credentials.create({ + integrationID: openai, + label: input.account ?? "Personal", + value: Credential.OAuth.make({ + type: "oauth", + methodID: method, + access: input.access ?? "codex-access-token", + refresh: "codex-refresh-token", + expires: input.expires ?? Date.now() + 3_600_000, + metadata: input.account ? { accountID: input.account } : undefined, + }), + }) +}) + +describe("Codex provider usage service", () => { + it.live("uses OAuth bearer and account headers with bounded direct transport settings", () => + fixture( + async () => Response.json(payload()), + ({ usage, credentials, requests }) => + Effect.gen(function* () { + yield* connect(credentials, { account: "acct-personal" }) + const result = yield* usage.get() + const headers = new Headers(requests[0]?.init.headers) + + expect(requests).toHaveLength(1) + expect(requests[0]?.url).toBe("https://chatgpt.com/backend-api/wham/usage") + expect(requests[0]?.init).toMatchObject({ method: "GET", cache: "no-store", redirect: "error" }) + expect(requests[0]?.init.signal).toBeInstanceOf(AbortSignal) + expect(headers.get("authorization")).toBe("Bearer codex-access-token") + expect(headers.get("chatgpt-account-id")).toBe("acct-personal") + expect(result.items).toHaveLength(1) + expect(result.items[0]).toMatchObject({ + id: "codex-chatgpt", + providerID: "openai", + sourceKind: "direct", + fetchState: "ready", + planState: "active", + routingState: "not_applicable", + windows: [ + { + orientation: "used_percent", + unit: "percent", + used: 20, + remaining: 80, + limit: 100, + durationMs: 18_000_000, + period: { unit: "hour", value: 5 }, + state: "active", + }, + { + orientation: "used_percent", + used: 35, + remaining: 65, + durationMs: 604_800_000, + period: { unit: "week", value: 1 }, + }, + ], + }) + expect(JSON.stringify(result)).not.toContain("codex-access-token") + expect(JSON.stringify(result)).not.toContain("acct-personal") + }), + ), + ) + + it.live("omits the account header and accepts a successful response without windows", () => + fixture( + async () => Response.json(payload({ rate_limit: null })), + ({ usage, credentials, requests }) => + Effect.gen(function* () { + yield* connect(credentials) + const result = yield* usage.get() + + expect(new Headers(requests[0]?.init.headers).has("chatgpt-account-id")).toBe(false) + expect(result.items[0]).toMatchObject({ id: "codex-chatgpt", fetchState: "ready", windows: [] }) + }), + ), + ) + + it.live("refreshes expired OAuth credentials through the registered implementation and persists them", () => + fixture( + async () => Response.json(payload()), + ({ usage, credentials, requests, refreshes }) => + Effect.gen(function* () { + const saved = yield* connect(credentials, { account: "acct-refresh", expires: Date.now() - 1 }) + const result = yield* usage.get() + const stored = yield* credentials.get(saved.id) + const headers = new Headers(requests[0]?.init.headers) + + expect(result.items[0]?.fetchState).toBe("ready") + expect(refreshes.count).toBe(1) + expect(headers.get("authorization")).toBe("Bearer refreshed-access-token") + expect(headers.get("chatgpt-account-id")).toBe("acct-refresh") + expect(stored?.value).toMatchObject({ + type: "oauth", + access: "refreshed-access-token", + refresh: "rotated-refresh-token", + metadata: { accountID: "acct-refresh" }, + }) + expect((yield* usage.get()).items[0]?.fetchState).toBe("ready") + expect(refreshes.count).toBe(1) + expect(requests).toHaveLength(1) + }), + ), + ) + + it.live("preserves transient refresh failures only for the same active credential", () => { + const failure = { current: false } + return fixture( + async () => Response.json(payload()), + ({ usage, credentials, requests, refreshes }) => + Effect.gen(function* () { + const saved = yield* connect(credentials, { account: "acct-original" }) + const ready = (yield* usage.get()).items[0] + expect(ready?.fetchState).toBe("ready") + expect(ready?.windows[0]?.used).toBe(20) + + yield* credentials.update(saved.id, { + value: Credential.OAuth.make({ + type: "oauth", + methodID: method, + access: "codex-access-token", + refresh: "codex-refresh-token", + expires: Date.now() - 1, + metadata: { accountID: "acct-original" }, + }), + }) + failure.current = true + const stale = yield* usage.get() + + expect(stale.items[0]?.fetchState).toBe("stale") + expect(stale.items[0]?.windows[0]?.used).toBe(20) + expect(JSON.stringify(stale)).not.toContain("private OAuth refresh failure") + expect(requests).toHaveLength(1) + expect(refreshes.count).toBe(1) + + yield* connect(credentials, { account: "acct-replacement", expires: Date.now() - 1 }) + expect((yield* usage.get()).items).toEqual([]) + expect(requests).toHaveLength(1) + expect(refreshes.count).toBe(2) + }), + { failure: () => failure.current }, + ) + }) + + it.live("invalidates cached usage when an account changes despite reusing its access token", () => + fixture( + async (_input, init) => + Response.json( + payload({ + rate_limit: { + allowed: true, + limit_reached: false, + primary_window: window(new Headers(init?.headers).get("chatgpt-account-id") === "acct-first" ? 15 : 75), + }, + }), + ), + ({ usage, credentials, requests }) => + Effect.gen(function* () { + yield* connect(credentials, { access: "shared-access-token", account: "acct-first" }) + expect((yield* usage.get()).items[0]?.windows[0]).toMatchObject({ used: 15 }) + + yield* connect(credentials, { access: "shared-access-token", account: "acct-second" }) + expect((yield* usage.get()).items[0]?.windows[0]).toMatchObject({ used: 75 }) + expect(requests).toHaveLength(2) + expect(new Headers(requests[1]?.init.headers).get("chatgpt-account-id")).toBe("acct-second") + }), + ), + ) + + it.live("prevents an old in-flight account request from overwriting its replacement", () => + Effect.gen(function* () { + const started = yield* Deferred.make() + const pending = Promise.withResolvers() + + return yield* fixture( + (input, init) => { + if (new Headers(init?.headers).get("chatgpt-account-id") === "acct-old") { + Effect.runSync(Deferred.succeed(started, undefined)) + return pending.promise + } + return Promise.resolve(Response.json(payload({ rate_limit: { allowed: true, primary_window: window(70) } }))) + }, + ({ usage, credentials, requests }) => + Effect.gen(function* () { + yield* connect(credentials, { access: "shared-access-token", account: "acct-old" }) + const first = yield* usage.get().pipe(Effect.forkChild) + yield* Deferred.await(started).pipe(Effect.timeout("2 seconds")) + + yield* connect(credentials, { access: "shared-access-token", account: "acct-new" }) + const second = yield* usage.get() + expect(second.items[0]?.windows[0]).toMatchObject({ used: 70 }) + expect(requests).toHaveLength(2) + + pending.resolve(Response.json(payload({ rate_limit: { allowed: true, primary_window: window(10) } }))) + expect((yield* Fiber.join(first)).items).toEqual([]) + expect((yield* usage.get()).items[0]?.windows[0]).toMatchObject({ used: 70 }) + expect(requests).toHaveLength(2) + }), + ) + }), + ) + + it.live("rechecks a completed Codex result after a delayed sibling and account change", () => + Effect.gen(function* () { + const started = yield* Deferred.make() + const replaced = yield* Deferred.make() + const pending = Promise.withResolvers() + return yield* fixture( + async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + if (!url.includes("chatgpt.com")) { + Effect.runSync(Deferred.succeed(started, undefined)) + return pending.promise + } + const account = new Headers(init?.headers).get("chatgpt-account-id") + if (account === "acct-new") Effect.runSync(Deferred.succeed(replaced, undefined)) + return Response.json(payload({ rate_limit: { primary_window: window(account === "acct-new" ? 70 : 10) } })) + }, + ({ usage, credentials, integrations }) => + Effect.gen(function* () { + yield* connect(credentials, { account: "acct-old" }) + expect((yield* usage.get()).items[0]?.windows[0]?.used).toBe(10) + yield* integrations.connection.key({ integrationID: minimax, key: "sk-cp-sibling" }) + + const first = yield* usage.get().pipe(Effect.forkChild) + yield* Deferred.await(started).pipe(Effect.timeout("2 seconds")) + yield* Effect.yieldNow + yield* connect(credentials, { account: "acct-new" }) + const second = yield* usage.get().pipe(Effect.forkChild) + yield* Deferred.await(replaced).pipe(Effect.timeout("2 seconds")) + pending.resolve(native(80)) + + const previous = yield* Fiber.join(first) + expect(previous.items.map((item) => item.providerID)).toEqual(["minimax-coding-plan"]) + expect(previous.items[0]?.windows[0]?.remaining).toBe(80) + const current = yield* Fiber.join(second) + expect(current.items.find((item) => item.providerID === "openai")?.windows[0]?.used).toBe(70) + expect(current.items.find((item) => item.providerID === "minimax-coding-plan")?.windows[0]?.remaining).toBe( + 80, + ) + }), + { minimax: true }, + ) + }), + ) + + for (const state of ["disabled", "removed"]) { + it.live(`prunes cached usage when the OpenAI provider is ${state}`, () => + fixture( + async () => Response.json(payload()), + ({ usage, catalog, credentials, requests }) => + Effect.gen(function* () { + yield* connect(credentials) + expect((yield* usage.get()).items).toHaveLength(1) + + yield* catalog.transform((draft) => { + if (state === "removed") return draft.provider.remove(ProviderV2.ID.openai) + draft.provider.update(ProviderV2.ID.openai, (provider) => { + provider.disabled = true + }) + }) + expect((yield* usage.get()).items).toEqual([]) + expect(requests).toHaveLength(1) + }), + ), + ) + } + + it.live("prunes OAuth usage after API-key takeover and logout", () => + fixture( + async () => Response.json(payload()), + ({ usage, integrations, credentials, requests }) => + Effect.gen(function* () { + yield* connect(credentials, { account: "acct-key" }) + expect((yield* usage.get()).items).toHaveLength(1) + + yield* integrations.connection.key({ integrationID: openai, key: "private-api-key" }) + expect((yield* usage.get()).items).toEqual([]) + + const restored = yield* connect(credentials, { account: "acct-logout" }) + expect((yield* usage.get()).items).toHaveLength(1) + yield* integrations.connection.remove(restored.id) + expect((yield* usage.get()).items).toEqual([]) + expect(requests).toHaveLength(2) + }), + ), + ) + + it.live("keeps Codex and MiniMax independent when either upstream fails", () => + fixture( + (() => { + const calls = { codex: 0, minimax: 0 } + return async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + if (url.includes("chatgpt.com")) { + calls.codex++ + return calls.codex === 1 ? new Response("private codex failure", { status: 503 }) : Response.json(payload()) + } + calls.minimax++ + return calls.minimax === 1 ? native(80) : new Response("private minimax failure", { status: 503 }) + } + })(), + ({ usage, credentials }) => + Effect.gen(function* () { + yield* connect(credentials, { account: "acct-both" }) + yield* credentials.create({ + integrationID: minimax, + value: Credential.Key.make({ type: "key", key: "sk-cp-minimax-secret" }), + }) + + const first = yield* usage.get() + expect(first.items.find((item) => item.id === "codex-chatgpt")).toMatchObject({ + fetchState: "unavailable", + windows: [], + }) + expect(first.items.find((item) => item.id === "minimax-direct-global")).toMatchObject({ + fetchState: "ready", + windows: [{ remaining: 80 }], + }) + + const second = yield* usage.refresh() + expect(second.items.find((item) => item.id === "codex-chatgpt")).toMatchObject({ fetchState: "ready" }) + expect(second.items.find((item) => item.id === "minimax-direct-global")).toMatchObject({ + fetchState: "stale", + windows: [{ remaining: 80 }], + }) + expect(JSON.stringify([first, second])).not.toContain("private") + expect(JSON.stringify([first, second])).not.toContain("sk-cp-minimax-secret") + }), + { minimax: true }, + ), + ) + + for (const status of [200, 401, 403, 503]) { + it.live(`handles HTTP ${status} without exposing private upstream errors or invalid quota`, () => { + const responses = [Response.json(payload()), Response.json({ error: "private upstream secret" }, { status })] + const retryable = status !== 401 && status !== 403 + return fixture( + async () => responses.shift()!, + ({ usage, credentials }) => + Effect.gen(function* () { + yield* connect(credentials, { account: "acct-errors" }) + const ready = yield* usage.get() + const failed = yield* usage.refresh() + const item = failed.items[0] + + expect(ready.items[0]).toMatchObject({ fetchState: "ready", windows: [{ used: 20 }, { used: 35 }] }) + expect(item).toMatchObject({ + id: "codex-chatgpt", + fetchState: retryable ? "stale" : "unavailable", + error: { retryable }, + }) + expect(item?.windows).toEqual(retryable ? ready.items[0]?.windows : []) + expect(JSON.stringify(failed)).not.toContain("private upstream secret") + expect(JSON.stringify(failed)).not.toContain("codex-access-token") + }), + ) + }) + } +}) + +describe("Codex usage normalization", () => { + test("distinguishes malformed responses from legitimately absent windows", () => { + for (const input of [ + {}, + { error: "private upstream failure" }, + payload({ plan_type: " " }), + payload({ rate_limit: "invalid" }), + payload({ additional_rate_limits: {} }), + payload({ rate_limit: { primary_window: { used_percent: "invalid" } } }), + payload({ rate_limit: null, additional_rate_limits: [{ rate_limit: "invalid" }] }), + ]) + expect(() => decode(input)).toThrow("Codex usage is unavailable.") + for (const rate_limit of [undefined, null, { primary_window: null, secondary_window: null }]) { + expect(normalize(decode(payload({ rate_limit })))).toMatchObject({ fetchState: "ready", windows: [] }) + } + }) + + test("matches Codex plan labels without exposing unknown internal identifiers", () => { + for (const [plan, label] of [ + ["self_serve_business_prolite", "ChatGPT Business Premium"], + ["self_serve_business_usage_based", "ChatGPT Business"], + ["team", "ChatGPT Business"], + ["business", "ChatGPT Enterprise"], + ["ent26", "ChatGPT Enterprise"], + ["enterprise_cbp_automation", "ChatGPT Enterprise (Automation)"], + ["enterprise_cbp_usage_based", "ChatGPT Enterprise"], + ["enterprise", "ChatGPT Enterprise"], + ["edu", "ChatGPT Edu"], + ["education", "ChatGPT Edu"], + ["edu_plus", "ChatGPT Edu Plus"], + ["edu_pro", "ChatGPT Edu Pro"], + ["prolite", "ChatGPT Pro Lite"], + ["pro", "ChatGPT Pro"], + ["PLUS", "ChatGPT Plus"], + ["free", "ChatGPT Free"], + ["go", "ChatGPT Go"], + ["unknown_internal_plan", "ChatGPT Codex"], + ["constructor", "ChatGPT Codex"], + ["__proto__", "ChatGPT Codex"], + ] as const) { + expect(normalize(decode(payload({ plan_type: plan }))).planLabel).toBe(label) + } + }) + + test("preserves valid sibling windows when adjacent native windows are malformed", () => { + const value = decode( + payload({ + rate_limit: { + allowed: true, + primary_window: { ...window(20), used_percent: "invalid private usage" }, + secondary_window: window(45, 86_400), + }, + additional_rate_limits: [ + { limit_name: "Malformed", metered_feature: "bad", rate_limit: "invalid" }, + { + limit_name: "Spark", + metered_feature: "spark", + rate_limit: { allowed: true, primary_window: window(30, 3_600) }, + }, + ], + }), + ) + const item = normalize(value) + + expect(item.fetchState).toBe("ready") + expect(item.windows).toHaveLength(2) + expect(item.windows.map((entry) => entry.used)).toEqual([45, 30]) + expect(item.windows[0]).toMatchObject({ durationMs: 86_400_000, period: { unit: "day", value: 1 } }) + expect(JSON.stringify(item)).not.toContain("invalid private usage") + }) + + test("preserves independent window usage and native IDs when named quotas are reordered", () => { + const value = decode( + payload({ + rate_limit: { + allowed: false, + limit_reached: true, + primary_window: window(140), + secondary_window: window(35, 604_800), + }, + additional_rate_limits: [ + { + limit_name: "Spark Fast", + metered_feature: "spark-fast", + rate_limit: { allowed: false, limit_reached: true, primary_window: window(-10) }, + }, + { + limit_name: "Spark-Fast", + metered_feature: "spark_fast", + rate_limit: { allowed: true, limit_reached: false, primary_window: window(60) }, + }, + ], + }), + ) + const first = normalize(value) + const second = normalize({ + ...value, + additional: value.additional.toReversed().map((item) => ({ ...item, name: `${item.name} renamed` })), + }) + const ids = first.windows.map((entry) => entry.id) + + expect(first.fetchState).toBe("ready") + expect(first.windows).toHaveLength(4) + expect(first.windows[0]).toMatchObject({ used: 100, remaining: 0, state: "exhausted" }) + expect(first.windows[1]).toMatchObject({ used: 35, remaining: 65, state: "active" }) + expect(first.windows[2]).toMatchObject({ used: 0, remaining: 100, state: "active" }) + expect(first.windows[3]).toMatchObject({ used: 60, remaining: 40, state: "active" }) + expect(new Set(ids).size).toBe(ids.length) + expect(Object.fromEntries(second.windows.map((entry) => [entry.id, entry.used]))).toEqual( + Object.fromEntries(first.windows.map((entry) => [entry.id, entry.used])), + ) + }) + + test("retains non-round durations without fabricating a named period", () => { + const value = decode( + payload({ + rate_limit: { + allowed: true, + primary_window: window(20, 5_400), + secondary_window: window(40, 172_800), + }, + }), + ) + const item = normalize(value) + + expect(item.windows[0]).toMatchObject({ durationMs: 5_400_000 }) + expect(item.windows[0]?.period).toBeUndefined() + expect(item.windows[1]).toMatchObject({ durationMs: 172_800_000, period: { unit: "day", value: 2 } }) + }) + + test("falls back from overflowing timestamps and rejects overflowing fallback durations", () => { + const value = decode( + payload({ + rate_limit: { + allowed: true, + primary_window: { ...window(20), reset_at: Number.MAX_SAFE_INTEGER, reset_after_seconds: 3_600 }, + secondary_window: { ...window(30), reset_at: -1, reset_after_seconds: Number.MAX_SAFE_INTEGER }, + }, + }), + ) + const item = normalize(value) + + expect(item.fetchState).toBe("ready") + expect(item.windows[0]?.resetAt).toBe(new Date(Date.parse(item.fetchedAt!) + 3_600_000).toISOString()) + expect(item.windows[1]?.resetAt).toBeUndefined() + }) +}) + +describe("Codex usage transport", () => { + for (const mode of ["declared", "streamed"]) { + test(`rejects oversized ${mode} bodies without exposing their contents`, async () => { + const response = Response.json( + payload({ private: mode === "declared" ? "secret" : "secret".padEnd(64 * 1024, "x") }), + { headers: mode === "declared" ? { "content-length": String(64 * 1024 + 1) } : undefined }, + ) + const item = await load( + { label: "OpenAI", access: "codex-access-token" }, + Object.assign(async () => response, { preconnect: fetch.preconnect }), + ) + + expect(item).toMatchObject({ id: "codex-chatgpt", fetchState: "unavailable", windows: [] }) + expect(JSON.stringify(item)).not.toContain("secret") + }) + } +}) diff --git a/packages/core/test/kilocode/config-shared-agent-board.test.ts b/packages/core/test/kilocode/config-shared-agent-board.test.ts index 213879260e..d6c797c5a0 100644 --- a/packages/core/test/kilocode/config-shared-agent-board.test.ts +++ b/packages/core/test/kilocode/config-shared-agent-board.test.ts @@ -9,14 +9,14 @@ describe("shared agent board configuration", () => { test("is absent by default", () => { const config = decode({}) - expect(config.experimental?.shared_agent_board).toBeUndefined() - expect(encode(config).experimental?.shared_agent_board).toBeUndefined() + expect(config.shared_agent_board).toBeUndefined() + expect(encode(config).shared_agent_board).toBeUndefined() }) test.each([false, true])("parses and round-trips %s", (value) => { - const config = decode({ experimental: { shared_agent_board: value } }) + const config = decode({ shared_agent_board: value }) - expect(config.experimental?.shared_agent_board).toBe(value) - expect(encode(config).experimental?.shared_agent_board).toBe(value) + expect(config.shared_agent_board).toBe(value) + expect(encode(config).shared_agent_board).toBe(value) }) }) diff --git a/packages/core/test/kilocode/pty-latch.test.ts b/packages/core/test/kilocode/pty-latch.test.ts new file mode 100644 index 0000000000..6a1c1fd8a7 --- /dev/null +++ b/packages/core/test/kilocode/pty-latch.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test" +import { spawn } from "../../src/pty/pty.bun" + +const run = process.platform === "win32" ? test.skip : test + +// bun-pty fires each event once from its read loop. Without the latch, a child that exits +// before the caller attaches listeners loses both its output and its exit (0/20 delivered). +run("replays output and exit to listeners attached after the child exited", async () => { + const proc = spawn("sh", ["-c", 'printf "early"; exit 7'], { + name: "xterm", + cwd: "/tmp", + env: { PATH: process.env.PATH ?? "" }, + }) + await Bun.sleep(300) + + const chunks: string[] = [] + const exit = Promise.withResolvers<{ exitCode: number }>() + proc.onData((chunk) => chunks.push(chunk)) + proc.onExit((event) => exit.resolve(event)) + + const timeout = Bun.sleep(3000).then(() => { + throw new Error("timed out waiting for replayed exit") + }) + expect(await Promise.race([exit.promise, timeout])).toEqual({ exitCode: 7 }) + expect(chunks.join("")).toContain("early") +}) + +run("does not replay to a listener disposed before the microtask runs", async () => { + const proc = spawn("sh", ["-c", "exit 0"], { name: "xterm", cwd: "/tmp", env: { PATH: process.env.PATH ?? "" } }) + await Bun.sleep(300) + + const seen: unknown[] = [] + proc.onExit((event) => seen.push(event)).dispose() + await Bun.sleep(50) + expect(seen).toEqual([]) +}) diff --git a/packages/kilo-docs/__tests__/content-integrity.test.ts b/packages/kilo-docs/__tests__/content-integrity.test.ts index 37ee676011..b8e0d5dbbe 100644 --- a/packages/kilo-docs/__tests__/content-integrity.test.ts +++ b/packages/kilo-docs/__tests__/content-integrity.test.ts @@ -34,6 +34,8 @@ const removed = [ "automate/tools/update-todo-list", "automate/tools/use-mcp-tool", "automate/tools/write-to-file", + "kiloclaw/overview", + "kiloclaw/dashboard", ] function markdown(dir: string): string[] { diff --git a/packages/kilo-docs/__tests__/sitemap.test.ts b/packages/kilo-docs/__tests__/sitemap.test.ts index 37e814cb1b..67a49390a1 100644 --- a/packages/kilo-docs/__tests__/sitemap.test.ts +++ b/packages/kilo-docs/__tests__/sitemap.test.ts @@ -45,6 +45,8 @@ describe("sitemap.xml", () => { "/automate/tools/read-file", "/code-with-ai/features/fast-edits", "/ai-providers/vscode-lm", + "/kiloclaw/overview", + "/kiloclaw/dashboard", ] for (const route of removed) expect(xml).not.toContain(`https://kilo.ai/docs${route}`) diff --git a/packages/kilo-docs/components/PageFooter.tsx b/packages/kilo-docs/components/PageFooter.tsx index 23b75e2874..eda3415563 100644 --- a/packages/kilo-docs/components/PageFooter.tsx +++ b/packages/kilo-docs/components/PageFooter.tsx @@ -100,6 +100,16 @@ export function PageFooter() { Edit page +

+ Kilo has been acquired by Anaconda.{" "} + + Read the announcement + +

) diff --git a/packages/kilo-docs/components/SideNav.tsx b/packages/kilo-docs/components/SideNav.tsx index 8315394e39..a59e917ea5 100644 --- a/packages/kilo-docs/components/SideNav.tsx +++ b/packages/kilo-docs/components/SideNav.tsx @@ -16,7 +16,6 @@ const sectionNavItems: SectionNav = { contributing: Nav.ContributingNav, "ai-providers": Nav.AiProvidersNav, gateway: Nav.GatewayNav, - kiloclaw: Nav.KiloClawNav, } // Main nav items with their section keys @@ -29,7 +28,6 @@ const mainNavItems = [ { label: "Automate", href: "/automate", sectionKey: "automate" }, { label: "Deploy & Secure", href: "/deploy-secure", sectionKey: "deploy-secure" }, { label: "AI Gateway", href: "/gateway", sectionKey: "gateway" }, - { label: "KiloClaw", href: "/kiloclaw", sectionKey: "kiloclaw" }, { label: "Contributing", href: "/contributing", sectionKey: "contributing" }, ] diff --git a/packages/kilo-docs/components/TopNav.tsx b/packages/kilo-docs/components/TopNav.tsx index d0a7734a1d..29070a6164 100644 --- a/packages/kilo-docs/components/TopNav.tsx +++ b/packages/kilo-docs/components/TopNav.tsx @@ -31,7 +31,6 @@ const mainNavItems: NavItem[] = [ { label: "Automate", href: "/automate" }, { label: "Deploy & Secure", href: "/deploy-secure" }, { label: "Kilo Gateway", href: "/gateway" }, - { label: "KiloClaw", href: "/kiloclaw" }, { label: "Contributing", href: "/contributing" }, ] @@ -348,15 +347,6 @@ export function TopNav({ onMobileMenuToggle, isMobileMenuOpen = false, showMobil - {/* Announcement banner */} -
-

- The all-new Kilo Code extension is here, rebuilt on the{" "} - Kilo CLI for speed, flexibility, and continued - access to 500+ models via the Kilo Gateway → -

-
- ) diff --git a/packages/kilo-docs/lib/nav/index.ts b/packages/kilo-docs/lib/nav/index.ts index abfaef686d..e0514ac71a 100644 --- a/packages/kilo-docs/lib/nav/index.ts +++ b/packages/kilo-docs/lib/nav/index.ts @@ -7,7 +7,6 @@ import { CustomizeNav } from "./customize" import { DeploySecureNav } from "./deploy-secure" import { GatewayNav } from "./gateway" import { GettingStartedNav } from "./getting-started" -import { KiloClawNav } from "./kiloclaw" import { ToolsNav } from "./tools" export const Nav = { @@ -20,6 +19,5 @@ export const Nav = { ContributingNav, AiProvidersNav, GatewayNav, - KiloClawNav, ToolsNav, } diff --git a/packages/kilo-docs/lib/nav/kiloclaw.ts b/packages/kilo-docs/lib/nav/kiloclaw.ts deleted file mode 100644 index d880fcf6d9..0000000000 --- a/packages/kilo-docs/lib/nav/kiloclaw.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { NavSection } from "../types" - -export const KiloClawNav: NavSection[] = [ - { - title: "KiloClaw", - links: [ - { href: "/kiloclaw/overview", children: "Overview" }, - { href: "/kiloclaw/dashboard", children: "Dashboard" }, - { href: "/kiloclaw/pre-installed-software", children: "Pre-installed Software" }, - { href: "/kiloclaw/end-to-end", children: "End to End Config" }, - { - href: "/kiloclaw/control-ui/overview", - children: "Control UI", - subLinks: [ - { href: "/kiloclaw/control-ui/changing-models", children: "Changing Models" }, - { href: "/kiloclaw/control-ui/exec-approvals", children: "Exec Approvals" }, - { href: "/kiloclaw/control-ui/version-pinning", children: "Version Pinning" }, - ], - }, - { - href: "/kiloclaw/chat-platforms", - children: "Chat Platforms", - subLinks: [ - { href: "/kiloclaw/chat-platforms/telegram", children: "Telegram" }, - { href: "/kiloclaw/chat-platforms/discord", children: "Discord" }, - { href: "/kiloclaw/chat-platforms/slack", children: "Slack" }, - ], - }, - { - href: "/kiloclaw/development-tools", - children: "Integrations", - subLinks: [ - { href: "/kiloclaw/development-tools/github", children: "GitHub" }, - { href: "/kiloclaw/development-tools/google", children: "Google Workspace" }, - { href: "/kiloclaw/development-tools/linear", children: "Linear" }, - { href: "/kiloclaw/development-tools/composio", children: "Composio" }, - { href: "/kiloclaw/tools/1password", children: "1Password" }, - { href: "/kiloclaw/tools/brave-search", children: "Brave Search" }, - { href: "/kiloclaw/tools/agentcard", children: "AgentCard" }, - { href: "/kiloclaw/tools/other-tools", children: "Other Tools" }, - ], - }, - { - href: "/kiloclaw/triggers", - children: "Triggers", - subLinks: [ - { href: "/kiloclaw/triggers/webhooks", children: "Webhooks" }, - { href: "/kiloclaw/triggers/scheduled", children: "Scheduled" }, - ], - }, - { - href: "/kiloclaw/troubleshooting/common-questions", - children: "Troubleshooting", - subLinks: [ - { href: "/kiloclaw/troubleshooting/common-questions", children: "Common Questions" }, - { href: "/kiloclaw/troubleshooting/gateway-process", children: "Gateway Process States" }, - { href: "/kiloclaw/troubleshooting/architecture", children: "Architecture Notes" }, - ], - }, - { - href: "/kiloclaw/faq/general", - children: "FAQ", - subLinks: [ - { href: "/kiloclaw/faq/general", children: "General" }, - { href: "/kiloclaw/faq/pricing", children: "Pricing" }, - ], - }, - ], - }, -] diff --git a/packages/kilo-docs/next.config.js b/packages/kilo-docs/next.config.js index bc017eda4b..e1e1b417ae 100644 --- a/packages/kilo-docs/next.config.js +++ b/packages/kilo-docs/next.config.js @@ -14,11 +14,6 @@ module.exports = withMarkdoc(/* config: https://markdoc.io/docs/nextjs#options * basePath: false, permanent: true, }, - { - source: "/kiloclaw", - destination: "/kiloclaw/overview", - permanent: false, - }, ...previousDocsRedirects, ] }, diff --git a/packages/kilo-docs/pages/ai-providers/openai-chatgpt-plus-pro.md b/packages/kilo-docs/pages/ai-providers/openai-chatgpt-plus-pro.md index ed097eb574..441064c11e 100644 --- a/packages/kilo-docs/pages/ai-providers/openai-chatgpt-plus-pro.md +++ b/packages/kilo-docs/pages/ai-providers/openai-chatgpt-plus-pro.md @@ -13,7 +13,7 @@ If you already pay for ChatGPT Plus or Pro, you can use that subscription to run - **Flat-rate access to OpenAI models:** Your subscription covers usage without pay-as-you-go API costs. - **OAuth login — no API keys:** Click "Sign in to OpenAI Codex," authenticate in your browser, and you're done. - **Full agentic workflows:** Generate, refactor, debug, edit files, and run terminal commands inside Kilo Code. -- **Multiple AI modes:** Switch between Code, Plan, Debug, and Ask modes for different tasks. +- **Specialized agents:** Switch between the Code, Plan, Debug, and Ask agents for different tasks. {% callout type="note" %} Your ChatGPT subscription works with Kilo Code's core functionality (VS Code extension and CLI), but does **not** include cloud features such as Cloud Agents or Kilo Deploy. To use GPT models in those features, use the [Kilo Gateway](/docs/gateway). diff --git a/packages/kilo-docs/pages/automate/agent-manager-workflows.md b/packages/kilo-docs/pages/automate/agent-manager-workflows.md index 41db1c0f59..e91e5d5e00 100644 --- a/packages/kilo-docs/pages/automate/agent-manager-workflows.md +++ b/packages/kilo-docs/pages/automate/agent-manager-workflows.md @@ -169,6 +169,7 @@ Layer review in before asking a teammate: - **`/review`** — slash command, AI review of staged, unstaged, and untracked changes in the worktree when run without arguments. Good as a last pass before committing. - **`/review uncommitted [guidance]`** — explicitly review uncommitted changes, optionally focusing the review with guidance. - **`/review branch [base] [guidance]`** — review the whole branch vs. its detected or specified base, with optional guidance. +- **`/review worktree [guidance]`** - review committed, staged, unstaged, and untracked changes against the worktree's recorded parent branch. Available only in Agent Manager managed worktree sessions. - **`/review ` or `/review `** — review a specific commit or pull request. - **`kilo review` in CI** — automated PR review. See [Code Reviews](/docs/automate/code-reviews/overview) for the setup. - **Human review** — push the branch from the session terminal and `gh pr create`. The PR badge appears on the worktree and stays in sync with CI and reviews. Review, comment on, and merge the pull request from the internal PR panel; see [Reviewing a pull request](/docs/automate/agent-manager#reviewing-a-pull-request). @@ -198,11 +199,7 @@ Three ways, pick based on how much collaboration the change needs: ### Parent branch → worktree -When the parent branch moves ahead, ask the agent from the worktree's session: - -> Merge the latest `origin/main` into this branch and resolve any conflicts. Do not use `git stash`. - -Save this as a reusable slash command if you do it often. +When the parent branch moves ahead, run `/update-from-base` in the managed worktree's chat. It asks the agent to fetch and merge the saved base, preserving uncommitted edits without Git stash. The [Push Pull Request Fixes](/docs/automate/agent-manager#push-pull-request-fixes) setting controls whether it is also asked to push after checks pass. See [Update from the base branch](/docs/automate/agent-manager#update-from-the-base-branch) for details. {% callout type="danger" %} **Never use `git stash` inside a worktree.** Stashes live in the shared `.git` directory that every worktree points at, so a stash made in one worktree can be popped in another — crossing uncommitted changes between agents. Use a WIP commit or a temporary branch instead. @@ -216,7 +213,7 @@ The Agent Manager is good at conflict resolution when you give it context. A low ### When several worktrees finish at once -Merge the most foundational one first. Then, in each remaining worktree, ask the agent to pull the updated parent branch in (same prompt as above) before merging. The agent handles the merge direction and only escalates conflicts it cannot resolve. +Merge the most foundational one first. Then run `/update-from-base` in each remaining worktree before merging it. Give the agent context when a conflict needs a decision about the intended behavior. ## Hygiene diff --git a/packages/kilo-docs/pages/automate/code-reviews/github.md b/packages/kilo-docs/pages/automate/code-reviews/github.md index 753a25ffbe..afdd9ad3d2 100644 --- a/packages/kilo-docs/pages/automate/code-reviews/github.md +++ b/packages/kilo-docs/pages/automate/code-reviews/github.md @@ -79,14 +79,19 @@ When a review triggers: The repository list is synced from GitHub and can be refreshed from the configuration page. +### Per-repository overrides + +Turn automated reviews on or off for an individual repository to override the installation default. For example, disable a noisy repository or enable one repository while the default is off. Repositories with reviews disabled do not start automated reviews for incoming pull requests. + ## Troubleshooting ### Reviews are not triggering 1. Verify the GitHub App is installed and has access to the repository -2. Check that the Review Agent is **enabled** in the Code Reviews configuration +2. Check that reviews are enabled by the installation default or a per-repository override 3. Ensure the repository is in the allowed list (if using "Selected repositories" mode) -4. Confirm the PR is not a draft +4. Check that a per-repository override has not disabled reviews +5. Confirm the PR is not a draft ### Reviews are failing diff --git a/packages/kilo-docs/pages/automate/code-reviews/overview.md b/packages/kilo-docs/pages/automate/code-reviews/overview.md index e4d35150fe..75a936799c 100644 --- a/packages/kilo-docs/pages/automate/code-reviews/overview.md +++ b/packages/kilo-docs/pages/automate/code-reviews/overview.md @@ -124,6 +124,7 @@ Use `/review` for all local code reviews: - **`/review`** — Review uncommitted changes (staged, unstaged, and untracked) when run without arguments - **`/review uncommitted [guidance]`** — Review uncommitted changes with optional guidance - **`/review branch [base] [guidance]`** — Review your current branch vs. its detected or specified base, with optional guidance +- **`/review worktree [guidance]`** - In an Agent Manager worktree session, review committed, staged, unstaged, and untracked changes against the recorded parent branch - **`/review `** — Review a specific commit - **`/review `** — Review a pull request diff --git a/packages/kilo-docs/pages/automate/extending/shell-integration.md b/packages/kilo-docs/pages/automate/extending/shell-integration.md index 3903deafd6..49447d4907 100644 --- a/packages/kilo-docs/pages/automate/extending/shell-integration.md +++ b/packages/kilo-docs/pages/automate/extending/shell-integration.md @@ -59,7 +59,7 @@ When using the Kilo Code VS Code extension with the Agent Manager, each agent se | Shortcut | Action | |---|---| | Cmd+/ | Focus the session's terminal | -| Cmd+. | Cycle agent mode | +| Cmd+. | Cycle agents | ### Terminal Context Menu Actions diff --git a/packages/kilo-docs/pages/automate/integrations.md b/packages/kilo-docs/pages/automate/integrations.md index fb2146f314..036947ef20 100644 --- a/packages/kilo-docs/pages/automate/integrations.md +++ b/packages/kilo-docs/pages/automate/integrations.md @@ -30,7 +30,7 @@ Before connecting: - For GitHub: You need permission to install GitHub Apps for the repositories you want Kilo to access. - For GitLab: You need **Maintainer** role (or higher) on the projects you want to connect. - For DoltHub: You need a DoltHub account to authorize the OAuth connection. -- (Optional) If you're connecting an organization, you must be an admin or have app installation permissions. +- For a Kilo organization, adding a GitHub integration requires the **Owner** or **Admin** role, in addition to permission to install the app on GitHub. --- @@ -161,9 +161,9 @@ Once your integrations are connected, the following features are enabled in Kilo - Use DoltHub alongside GitHub or GitLab when a workflow also needs repository access - Authorize Gas Town Wasteland to fork commons databases, push claims and evidence, and manage DoltHub PRs. Wasteland also supports an advanced API token option when OAuth is not available. -### Upcoming: +### Bitbucket -- **Bitbucket Integration** +Organization accounts can connect Bitbucket Cloud and select its repositories when starting [Cloud Agent sessions](/docs/code-with-ai/platforms/cloud-agent) on the web or in the mobile app. Connect Bitbucket from your organization's **Integrations** page. Bitbucket is not available for personal accounts. --- @@ -171,6 +171,8 @@ Once your integrations are connected, the following features are enabled in Kilo ### GitHub +An organization Owner or Admin can connect multiple GitHub organizations. Click **Add organization** and repeat the installation flow. Each installation has its own connection status, repository scope, selected repository list, and management actions. Personal accounts use a single GitHub connection. + From the **Integrations** page, click "Manage on GitHub" to: - View the GitHub account you connected diff --git a/packages/kilo-docs/pages/code-with-ai/agents/orchestrator-mode.md b/packages/kilo-docs/pages/code-with-ai/agents/orchestrator-mode.md index 1f1bead1d4..fe92e6bd02 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/orchestrator-mode.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/orchestrator-mode.md @@ -25,7 +25,9 @@ Now, **subagent support is built into agents that have full tool access** (Code, 1. The agent analyzes a complex task and decides a subtask would benefit from isolation. 2. It launches a subagent session using the `task` tool (e.g., `general` for autonomous work, `explore` for codebase research). -3. The subagent runs in its own isolated context — separate conversation history, no shared state. -4. When done, the subagent returns a summary to the parent agent, which continues its work. +3. The subagent has a separate conversation history but shares the parent's project directory or worktree. It does not isolate file edits. +4. A foreground task returns its result before the parent continues. A background task lets the parent continue immediately and delivers its result later. Agents can launch multiple subagent sessions concurrently for parallel work. + +[Kilo Swarm](/docs/getting-started/settings#kilo-swarm) lets a main session and its task descendants exchange findings on a shared board. It works with your current agent and does not require the deprecated Orchestrator mode. Swarm is on by default; turn it off in **Settings > Agent Behaviour** or set `shared_agent_board` to `false` in `kilo.jsonc`. For separate branches and checkouts, use [Agent Manager worktree sessions](/docs/automate/agent-manager#orchestration-model) instead. diff --git a/packages/kilo-docs/pages/code-with-ai/agents/using-agents.md b/packages/kilo-docs/pages/code-with-ai/agents/using-agents.md index 7f6ddf8cfc..649640185f 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/using-agents.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/using-agents.md @@ -68,7 +68,7 @@ There are several ways to switch agents: | **Description** | An experienced technical leader and planner who helps design systems and create implementation plans | | **Tool Access** | Read-only tools plus restricted file editing (plan files in `.kilo/plans/` only) | | **Ideal For** | System design, high-level planning, and architecture discussions | -| **Special Features** | Similar to the legacy extension's "Architect" mode, with a planning-focused approach | +| **Special Features** | Similar to the legacy extension's "Architect" mode, with a planning-focused approach. In VS Code, the saved plan opens in the editor when ready for review. | ### debug diff --git a/packages/kilo-docs/pages/code-with-ai/features/code-actions.md b/packages/kilo-docs/pages/code-with-ai/features/code-actions.md index b7de4ff6bc..6fa1809df3 100644 --- a/packages/kilo-docs/pages/code-with-ai/features/code-actions.md +++ b/packages/kilo-docs/pages/code-with-ai/features/code-actions.md @@ -15,7 +15,7 @@ Code Actions are a **VS Code extension feature** and are not available in the CL The extension provides code actions via the editor context menu and lightbulb: -- **Add to Context:** Adds selected code (with file path and line numbers) to the active chat session. Keyboard shortcut: `Cmd+K Cmd+A` (Mac) or `Ctrl+K Ctrl+A` (Windows/Linux). +- **Add to Context:** Adds selected code (with file path and line numbers) to the active chat session. The selection appears in the chat input as a collapsible context card. Expand a card to review the code, open the file, or remove it. Keyboard shortcut: `Cmd+K Cmd+A` (Mac) or `Ctrl+K Ctrl+A` (Windows/Linux). - **Explain Code:** Asks Kilo to explain the selected code. - **Fix Code:** Asks Kilo to fix problems in the selected code. - **Improve Code:** Asks Kilo to suggest improvements to the selected code. diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md index 1e10a5d691..cf1fd640ea 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md @@ -746,6 +746,42 @@ Options: --version Show version number [boolean] ``` +## kilo github + +``` +manage GitHub agent + +Commands: + kilo github install install the GitHub agent + kilo github run run the GitHub agent + +Options: + --help Show help [boolean] + --version Show version number [boolean] +``` + +### kilo github install + +``` +install the GitHub agent + +Options: + --help Show help [boolean] + --version Show version number [boolean] +``` + +### kilo github run + +``` +run the GitHub agent + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --event GitHub mock event to run the agent for [string] + --token GitHub personal access token (github_pat_********) [string] +``` + ## kilo pr ``` @@ -941,8 +977,6 @@ Options: ## kilo console -Open Kilo Console to manage CLI configuration, including **Settings > CLI > Notifications**. See [CLI Notifications and Sounds](/docs/code-with-ai/platforms/cli#cli-notifications-and-sounds) for the equivalent `tui.json` settings and custom sound overrides. - ``` open or stop the local Kilo Console (deprecated) @@ -994,30 +1028,36 @@ Options: start a Cloud Agent task Options: - --help Show help [boolean] - --version Show version number [boolean] - --prompt prompt for the Cloud Agent [string] [required] - --repo repository shorthand or URL [string] - --repo-type repository provider type [string] [choices: "github", "gitlab", "git"] - --branch repository branch [string] - --model Cloud Agent model [string] - --mode Cloud Agent mode [string] - --org-id Kilo organization ID [string] - --stream connect to the WebSocket stream and print events as JSONL [boolean] + --help Show help [boolean] + --version Show version number [boolean] + --prompt prompt for the Cloud Agent [string] + --prompt-stdin read the prompt from standard input [boolean] [default: false] + --repo repository shorthand or URL [string] + --repo-type repository provider type [string] [choices: "github", "gitlab", "git"] + --branch repository branch [string] + --model Cloud Agent model [string] + --mode Cloud Agent mode [string] + --org-id Kilo organization ID [string] + --stream connect to the WebSocket stream and print events as JSONL [boolean] ``` +Provide exactly one of `--prompt` or `--prompt-stdin`. For example, read a multiline prompt from a file with `kilo cloud start --repo owner/repo --prompt-stdin < prompt.txt`. + ### kilo cloud send ``` send a follow-up prompt to a Cloud Agent task Options: - --help Show help [boolean] - --version Show version number [boolean] - --session-id Cloud Agent session ID [string] [required] - --prompt follow-up prompt for the Cloud Agent [string] [required] + --help Show help [boolean] + --version Show version number [boolean] + --prompt prompt for the Cloud Agent [string] + --prompt-stdin read the prompt from standard input [boolean] [default: false] + --session-id Cloud Agent session ID [string] [required] ``` +Provide exactly one of `--prompt` or `--prompt-stdin`, as with `kilo cloud start`. + ### kilo cloud status ``` diff --git a/packages/kilo-docs/pages/collaborate/enterprise/sso.md b/packages/kilo-docs/pages/collaborate/enterprise/sso.md index b52f3a8ee6..3397f74815 100644 --- a/packages/kilo-docs/pages/collaborate/enterprise/sso.md +++ b/packages/kilo-docs/pages/collaborate/enterprise/sso.md @@ -54,6 +54,8 @@ Copy the Service Provider details (Entity ID, ACS URL, and Metadata) from the Wo 1. Set the organization policy and user provisioning settings according to your organization's needs. 2. Configure domain policy and domain verification in WorkOS. +[Verified-domain auto-join](/docs/collaborate/teams/team-management#joining-automatically-with-a-verified-domain) is separate from SSO domain policy. It adds users with a matching email domain to your organization and does not require SSO. + After enabling SSO: - Invite new users with their company email domain. diff --git a/packages/kilo-docs/pages/collaborate/teams/getting-started.md b/packages/kilo-docs/pages/collaborate/teams/getting-started.md index 827a229213..446c1c616a 100644 --- a/packages/kilo-docs/pages/collaborate/teams/getting-started.md +++ b/packages/kilo-docs/pages/collaborate/teams/getting-started.md @@ -66,7 +66,7 @@ Team members receive invitation emails with these steps: ## First Steps for Your Team 1. **Try basic tasks** - code generation, debugging, documentation -2. **Explore different modes** - Code, Architect, Ask, Debug +2. **Explore different agents** - Code, Plan, Ask, Debug 3. **Set personal preferences** - model selection, auto-approval settings 4. **Review usage patterns** in the dashboard after first week diff --git a/packages/kilo-docs/pages/collaborate/teams/team-management.md b/packages/kilo-docs/pages/collaborate/teams/team-management.md index b78e67d9a6..7a6049c503 100644 --- a/packages/kilo-docs/pages/collaborate/teams/team-management.md +++ b/packages/kilo-docs/pages/collaborate/teams/team-management.md @@ -5,11 +5,11 @@ description: "Add and manage team members in Kilo Code" # Managing Your Team -Every person on the team is an _Owner_ or a _Member_. +Team roles are _Owner_, _Admin_, _Member_, and _Billing Manager_. Owners have full administrative oversight including billing, seat allocation, and model/provider selection. -Only Owners can conduct team management activities. +Owners and Admins can manage the team. Only Owners can grant the Owner role or manage another Owner's membership. Members can use the Kilo Code extension and see data on the team's usage in the [usage dashboard](/docs/collaborate/teams/analytics). @@ -18,11 +18,23 @@ Members can use the Kilo Code extension and see data on the team's usage in the 1. **Navigate to Organization Tab** in your profile page and click on the team you want to manage 2. **Click "Invite Member"** button 3. **Enter the team member's email address** -4. **Select initial role** (Member or Owner) +4. **Select an initial role** from the available options 5. Click **Send Invitation** {% image src="/docs/img/team-management/invite-member.png" alt="invite-member" width="619" caption="invite-member" /%} +## Joining automatically with a verified domain + +An organization Owner or Admin can verify an email domain so users with matching addresses join automatically: + +1. Open the **Verified Domains** card in your Organization dashboard. +2. Add your company's email domain and follow the WorkOS verification steps to prove ownership. +3. Return to **Verified Domains** and click **Check status**. If verification is still pending, check again until the domain shows **Verified**. + +After verification, matching users join as Members when they sign in through the browser, SSO, or the mobile app. They keep their personal account and other organization memberships. Removed members are not automatically re-added, and elevated roles still require an explicit role change. + +Auto-join does not require SSO. See [SSO setup](/docs/collaborate/enterprise/sso) to configure single sign-on separately. + ## Removing Team Members When team members leave: @@ -39,7 +51,7 @@ Promote or demote team members as needed: 1. **Locate team member** in Organization tab 2. **Click role dropdown** next to their name -3. **Select new role** (Member, Owner) +3. **Select a new role** from the available options 4. **Confirm change** 5. **Member receives email notification** diff --git a/packages/kilo-docs/pages/community/index.md b/packages/kilo-docs/pages/community/index.md index dfdeee502c..c5be644da8 100644 --- a/packages/kilo-docs/pages/community/index.md +++ b/packages/kilo-docs/pages/community/index.md @@ -15,8 +15,6 @@ These resources are maintained by the community unless explicitly noted otherwis - **[Kilo Marketplace](https://github.com/Kilo-Org/kilo-marketplace)** Share and install community-created Modes, Skills, and MCP servers. -- **[Kilo Code Show and Tell Discussions](https://github.com/Kilo-Org/kilocode/discussions/categories/show-and-tell)** - Real examples from users building workflows with Kilo Code. - **[MCP Official Resources](https://github.com/modelcontextprotocol)** Reference implementations and docs for MCP servers used with Kilo. @@ -32,4 +30,4 @@ Before adopting a community project, check: ## Share Your Project Built something useful with Kilo Code? -Share it in [Show and Tell](https://github.com/Kilo-Org/kilocode/discussions/categories/show-and-tell) or contribute it to the [Kilo Marketplace](https://github.com/Kilo-Org/kilo-marketplace). +Contribute it to the [Kilo Marketplace](https://github.com/Kilo-Org/kilo-marketplace). diff --git a/packages/kilo-docs/pages/contributing/architecture/cli-runtime.md b/packages/kilo-docs/pages/contributing/architecture/cli-runtime.md index a8275709ef..81fed7f04d 100644 --- a/packages/kilo-docs/pages/contributing/architecture/cli-runtime.md +++ b/packages/kilo-docs/pages/contributing/architecture/cli-runtime.md @@ -25,7 +25,7 @@ These terms describe local execution. They are separate from hosted Cloud Agent | Local routing workspace | Optional routing context that can resolve to a local directory or remote target | | Worktree directory | Alternate git worktree path used as directory context for isolated concurrent work | | Process-shared state | Runtime service state shared by every directory context in one Kilo CLI process | -| Modes | Configurable agent presets for tools, prompts, restrictions, and behavior | +| Agents | Configurable presets for tools, prompts, restrictions, and behavior | | MCP | Protocol for extending agent tools | One `kilo serve` process can host several local runtime instances. Directory-keyed state stays isolated. Process-shared service state does not. diff --git a/packages/kilo-docs/pages/contributing/architecture/vscode-extension.md b/packages/kilo-docs/pages/contributing/architecture/vscode-extension.md index eff1bef6bb..abe614540d 100644 --- a/packages/kilo-docs/pages/contributing/architecture/vscode-extension.md +++ b/packages/kilo-docs/pages/contributing/architecture/vscode-extension.md @@ -54,10 +54,10 @@ Shared service has more consumers than chat tabs: | Family | Consumers | |---|---| | Chat | Sidebar provider and editor-tab providers | -| Panels | Settings, profile and marketplace surfaces, sub-agent viewers, Agent Manager, KiloClaw | +| Panels | Settings, profile and marketplace surfaces, sub-agent viewers, Agent Manager | | Diff | Diff Viewer, Diff Virtual, and diff source catalog | | Editor assistance | Autocomplete and commit-message generation | -| Integrations | Browser automation MCP registration and KiloClaw bootstrap | +| Integrations | Browser automation MCP registration | New mutable state must account for concurrent consumers and multiple directory contexts on one process. @@ -119,7 +119,7 @@ Agent Manager PTY WebSocket URL uses `auth_token=` query m | Config owner | Examples | |---|---| | VS Code settings | `kilo-code.new.*` extension UI, proxy, autocomplete, and integration settings | -| CLI config | Global and project `kilo.jsonc`, `kilo.json`, compatible OpenCode files, provider auth, tools, permissions, modes | +| CLI config | Global and project `kilo.jsonc`, `kilo.json`, compatible OpenCode files, provider auth, tools, permissions, agents | Extension-specific behavior belongs in VS Code settings. Agent runtime behavior belongs in CLI config so TUI, Console, VS Code, and JetBrains can share it. @@ -152,7 +152,6 @@ Speech-to-text captures audio locally, then sends completed recording through sh | Extension host | `src/extension.ts` | `dist/extension.js` | | Sidebar and editor chat webview | `webview-ui/src/index.tsx` | `dist/webview.js` | | Agent Manager webview | `webview-ui/agent-manager/index.tsx` | `dist/agent-manager.js` | -| KiloClaw webview | `webview-ui/kiloclaw/index.tsx` | `dist/kiloclaw.js` | | Diff Viewer webview | `webview-ui/diff-viewer/index.tsx` | `dist/diff-viewer.js` | | Diff Virtual webview | `webview-ui/diff-virtual/index.tsx` | `dist/diff-virtual.js` | | Shared Shiki worker | synthetic worker entry | `dist/shiki-worker.js` | diff --git a/packages/kilo-docs/pages/contributing/index.md b/packages/kilo-docs/pages/contributing/index.md index e3e4b444ea..c0ff9721ef 100644 --- a/packages/kilo-docs/pages/contributing/index.md +++ b/packages/kilo-docs/pages/contributing/index.md @@ -5,10 +5,6 @@ description: "Contribute to Kilo Code" # Contributing Overview -{% callout type="info" %} -**New versions of the VS Code extension and CLI are being developed in [Kilo-Org/kilocode](https://github.com/Kilo-Org/kilocode)** (extension at `packages/kilo-vscode`, CLI at `packages/opencode`). If you're looking to contribute to the extension or CLI, please head over to that repository. -{% /callout %} - Kilo Code is an open-source project that welcomes contributions from developers of all skill levels. This guide will help you get started with contributing to Kilo Code, whether you're fixing bugs, adding features, improving documentation, or sharing custom modes. ## Ways to Contribute diff --git a/packages/kilo-docs/pages/customize/context/context-condensing.md b/packages/kilo-docs/pages/customize/context/context-condensing.md index 9339ed5509..bc1d398948 100644 --- a/packages/kilo-docs/pages/customize/context/context-condensing.md +++ b/packages/kilo-docs/pages/customize/context/context-condensing.md @@ -95,7 +95,7 @@ Compaction is configured in your `kilo.jsonc` file: ### Use a different model for compaction -Summarization can use a cheaper or larger-context model than your main agent. Configure a dedicated compaction agent: +Summarization can use a different model than your main agent. In VS Code, choose **Compaction model** under **Settings → Models**. The Compaction section in **Settings → Context** links to this selector. You can also configure a dedicated compaction agent in `kilo.jsonc`: ```jsonc { @@ -109,6 +109,8 @@ Summarization can use a cheaper or larger-context model than your main agent. Co If no compaction agent is set, the current session's model is used. +The selection applies to automatic and manual compaction without changing your chat model. Clear it to use the current session's model again. + ### Environment overrides | Variable | Effect | @@ -194,7 +196,7 @@ Compaction is configured in your `kilo.jsonc` file: ### Use a different model for compaction -Summarization can use a cheaper or larger-context model than your main agent. Configure a dedicated compaction agent: +Summarization can use a different model than your main agent. Configure a dedicated compaction agent: ```jsonc { diff --git a/packages/kilo-docs/pages/customize/custom-instructions.md b/packages/kilo-docs/pages/customize/custom-instructions.md index 064f9be342..ce91a76064 100644 --- a/packages/kilo-docs/pages/customize/custom-instructions.md +++ b/packages/kilo-docs/pages/customize/custom-instructions.md @@ -42,7 +42,7 @@ Place any of these files at your project root to provide project-wide instructio For instructions that apply across all your projects, place an `AGENTS.md` file in your global config directory: - **Kilo:** `~/.config/kilo/AGENTS.md` -- **Claude-compatible:** `~/.claude/CLAUDE.md` +- **Claude-compatible:** `~/.claude/CLAUDE.md`, until [Claude Code Migration](/docs/getting-started/settings#claude-code-migration) has been attempted. Migration ends this global fallback; project-level `CLAUDE.md` files keep working. Project-level instructions are loaded before global instructions and apply to every session. @@ -119,7 +119,7 @@ Place any of these files at your project root to provide project-wide instructio For instructions that apply across all your projects, place an `AGENTS.md` file in your global config directory: - **Kilo:** `~/.config/kilo/AGENTS.md` -- **Claude-compatible:** `~/.claude/CLAUDE.md` +- **Claude-compatible:** `~/.claude/CLAUDE.md`, until [Claude Code Migration](/docs/getting-started/settings#claude-code-migration) has been attempted. Migration ends this global fallback; project-level `CLAUDE.md` files keep working. Project-level instructions are loaded before global instructions and apply to every session. diff --git a/packages/kilo-docs/pages/customize/custom-modes.md b/packages/kilo-docs/pages/customize/custom-modes.md index 8fcb4d5aee..442777eee7 100644 --- a/packages/kilo-docs/pages/customize/custom-modes.md +++ b/packages/kilo-docs/pages/customize/custom-modes.md @@ -1,35 +1,35 @@ --- title: "Custom Modes" -description: "Create and configure custom modes in Kilo Code" +description: "Create and configure custom agents in Kilo Code" --- # Custom Modes -Kilo Code allows you to create **custom modes** (also called **agents**) to tailor Kilo's behavior to specific tasks or workflows. Custom modes can be **global** (available across all projects), **project-specific** (defined within a single project), or **organization-managed** (provided by your Kilo organization). +Kilo Code allows you to create **custom agents** (also called **custom modes**) to tailor Kilo's behavior to specific tasks or workflows. Custom agents can be **global** (available across all projects), **project-specific** (defined within a single project), or **organization-managed** (provided by your Kilo organization). {% callout type="info" %} -The current VS Code extension (built on the Kilo CLI) uses **agent Markdown files** to define custom modes. The legacy extension used `custom_modes.yaml` / `.kilocodemodes`. See the tabs below for the relevant approach. +The current VS Code extension (built on the Kilo CLI) uses **agent Markdown files** to define custom agents. The legacy extension used `custom_modes.yaml` / `.kilocodemodes`. See the tabs below for the relevant approach. {% /callout %} -## Why Use Custom Modes? +## Why use custom agents? {% #why-use-custom-modes %} -- **Specialization:** Create modes optimized for specific tasks, like "Documentation Writer," "Test Engineer," or "Refactoring Expert" -- **Safety:** Restrict a mode's access to sensitive files or commands. For example, a "Review Mode" could be limited to read-only operations -- **Experimentation:** Safely experiment with different prompts and configurations without affecting other modes -- **Team Collaboration:** Share custom modes with your team to standardize workflows -- **Organization Consistency:** Use organization-managed agents/custom modes so members share the same behavior for common workflows +- **Specialization:** Create agents optimized for specific tasks, like "Documentation Writer," "Test Engineer," or "Refactoring Expert" +- **Safety:** Restrict an agent's access to sensitive files or commands. For example, a "Review" agent could be limited to read-only operations +- **Experimentation:** Safely experiment with different prompts and configurations without affecting other agents +- **Team Collaboration:** Share custom agents with your team to standardize workflows +- **Organization Consistency:** Use organization-managed agents so members share the same behavior for common workflows -## Organization-Managed Custom Modes +## Organization-managed agents {% #organization-managed-custom-modes %} -If your Kilo organization provides custom modes, Kilo adds them to your local experience as organization-sourced agents/custom modes. They appear alongside built-in and personal agents so members can select them directly where Kilo shows user-selectable agents or modes. +If your Kilo organization provides custom modes, the VS Code extension and CLI load them as organization-sourced agents. They appear alongside built-in and personal agents in the agent picker. -Organization-managed modes are controlled at the organization level: +Organization-managed agents are controlled at the organization level: -- An organization-managed mode can use the same name as a built-in agent. When it does, the organization-provided definition takes precedence for members of that organization. -- Individual members cannot remove organization-managed modes from their local agent list. Changes need to be made in the organization-managed definition. -- Organization-managed modes are useful for shared prompts, instructions, and tool access expectations that should stay consistent across a team. +- An organization-managed agent can use the same name as a built-in agent. When it does, the organization-provided definition takes precedence for members of that organization. +- Individual members cannot remove organization-managed agents from their local agent list. Changes need to be made in the organization-managed definition. +- Organization-managed agents are useful for shared prompts, instructions, and tool access expectations that should stay consistent across a team. -For organization members, contact the person or team that manages Kilo for your organization if an organization mode appears unexpectedly, needs different instructions, or needs different tool access. For admins and support teams, keep the purpose and owner of each organization custom mode clear so members know when to use it and where to request changes. +For organization members, contact the person or team that manages Kilo for your organization if an organization agent appears unexpectedly, needs different instructions, or needs different tool access. For admins and support teams, keep the purpose and owner of each organization-managed agent clear so members know when to use it and where to request changes. {% tabs %} {% tab label="VSCode" %} @@ -740,7 +740,3 @@ Focus on: {% /tab %} {% /tabs %} - -## Community Gallery - -Ready to explore more? Check out the [Show and Tell](https://github.com/Kilo-Org/kilocode/discussions/categories/show-and-tell) to discover and share custom modes and agents created by the community! diff --git a/packages/kilo-docs/pages/customize/index.md b/packages/kilo-docs/pages/customize/index.md index f39a30ea81..383616387e 100644 --- a/packages/kilo-docs/pages/customize/index.md +++ b/packages/kilo-docs/pages/customize/index.md @@ -1,19 +1,19 @@ --- title: "Customize" -description: "Make Kilo Code work your way with custom modes, rules, instructions, and more" +description: "Make Kilo Code work your way with custom agents, rules, instructions, and more" --- # {% $markdoc.frontmatter.title %} {% callout type="generic" %} -Kilo Code is highly customizable. Tailor its behavior to match your workflow, team standards, and project requirements with custom modes, rules, instructions, and more. +Kilo Code is highly customizable. Tailor its behavior to match your workflow, team standards, and project requirements with custom agents, rules, instructions, and more. {% /callout %} ## Customization Configure how Kilo Code behaves and responds: -- [**Custom Modes**](/docs/customize/custom-modes) - Create specialized modes for different tasks (code review, documentation, testing, etc.) +- [**Custom Modes**](/docs/customize/custom-modes) - Create specialized agents for different tasks (code review, documentation, testing, etc.) - [**Custom Rules**](/docs/customize/custom-rules) - Define rules that apply to specific file types or situations - [**Custom Instructions**](/docs/customize/custom-instructions) - Add project-specific guidelines and context - [**Custom Subagents**](/docs/customize/custom-subagents) - Create specialized subagents with custom prompts, models, and permissions @@ -36,14 +36,14 @@ Help Kilo understand your codebase better: New to customization? Here's where to start: 1. **Start with Custom Instructions** — Set up instructions in the [Custom Instructions](/docs/customize/custom-instructions) section to guide Kilo Code's behavior -2. **Explore Custom Modes** — Try the built-in modes first, then create your own +2. **Explore Custom Agents** — Try the built-in agents first, then create your own 3. **Enable Codebase Indexing** — Help Kilo understand your project structure ## Best Practices - Keep custom instructions concise and actionable -- Use custom modes for repetitive tasks -- Combine rules with modes for powerful workflows +- Use custom agents for repetitive tasks +- Combine rules with agents for powerful workflows ## Next Steps diff --git a/packages/kilo-docs/pages/customize/skills.md b/packages/kilo-docs/pages/customize/skills.md index 415e152215..d3cda2622f 100644 --- a/packages/kilo-docs/pages/customize/skills.md +++ b/packages/kilo-docs/pages/customize/skills.md @@ -100,6 +100,8 @@ You can configure extra skill locations and remote skill URLs in your `kilo.json The `skills.paths` key accepts absolute paths, `~/` home-relative paths, or paths relative to the project root. The `skills.urls` key accepts URLs to remote skill directories that serve an `index.json` manifest. +A path that starts with `/` or `\` but has no drive letter, such as `/.github/skills`, is tried as an absolute path first. If that directory does not exist, Kilo resolves it relative to the project root instead, so `/.github/skills` and `.github/skills` load the same repository skills. Skills loaded through this fallback are treated as project skills. + The remote server must serve an `index.json` file at the URL path with the following structure: ```json @@ -172,6 +174,8 @@ You can configure extra skill locations and remote skill URLs in your `kilo.json The `skills.paths` key accepts absolute paths, `~/` home-relative paths, or paths relative to the project root. The `skills.urls` key accepts URLs to remote skill directories that serve an `index.json` manifest. +A path that starts with `/` or `\` but has no drive letter, such as `/.github/skills`, is tried as an absolute path first. If that directory does not exist, Kilo resolves it relative to the project root instead, so `/.github/skills` and `.github/skills` load the same repository skills. Skills loaded through this fallback are treated as project skills. + The remote server must serve an `index.json` file at the URL path with the following structure: ```json @@ -220,11 +224,15 @@ If you need a skill to only apply in certain situations, write a clear and speci When multiple skills share the same name, project-level skills (`.kilo/skills/`) take precedence over global skills (`~/.kilo/skills/`). Skills from compatibility directories (`.claude/skills/`, `.agents/skills/`) and additional configured paths are loaded alongside project and global skills. +Every loaded skill is also available as a slash command. The `/` menu lists skills in a separate **Skills** group. When a skill shares its name with a custom command or MCP prompt, the command keeps `/name` and the skill is listed as `/name:skill`, so both stay reachable. + {% /tab %} {% tab label="CLI" %} When multiple skills share the same name, project-level skills (`.kilo/skills/`) take precedence over global skills (`~/.kilo/skills/`). Skills from compatibility directories (`.claude/skills/`, `.agents/skills/`) and additional configured paths are loaded alongside project and global skills. +Every loaded skill is also available as a slash command. When a skill shares its name with a custom command or MCP prompt, the command keeps `/name` and the skill is offered as `/name:skill` in autocomplete, so both stay reachable. + {% /tab %} {% /tabs %} diff --git a/packages/kilo-docs/pages/deploy-secure/security-reviews.md b/packages/kilo-docs/pages/deploy-secure/security-reviews.md index 22c25c8fdc..9339a83149 100644 --- a/packages/kilo-docs/pages/deploy-secure/security-reviews.md +++ b/packages/kilo-docs/pages/deploy-secure/security-reviews.md @@ -375,9 +375,11 @@ SLA notifications require both SLA tracking and SLA notifications to be enabled. ## Notification delivery -Security Agent currently sends notifications only by email. +Security Agent sends notifications by email and mobile push. -Notification kinds: +Mobile push covers analysis completion or failure, and remediation being queued, opening a pull request, failing, being blocked, needing no changes, or being cancelled. These updates go to the owning user for personal accounts or organization owners, subject to their security notification preferences. + +Email notification kinds: | Kind | When eligible | |---|---| @@ -408,6 +410,8 @@ The audit report shows Security Finding activity recorded for an owner during a - `/security-agent/audit-report` - `/organizations/:organizationId/security-agent/audit-report` +You can also open the audit report in the mobile app to see the report period and each finding's recorded activity. + The audit report is based on activity recorded by Kilo. It does not prove that every historical event is present, show repository scan coverage, or provide aggregate SLA compliance. ### Report period and filters @@ -480,7 +484,7 @@ The following capabilities are not yet implemented but are being considered for - GitLab support for Security Agent findings and remediation. - Security Finding sources beyond Dependabot alerts, such as npm audit and SBOM analysis. -- Notification channels beyond email. +- Notification channels beyond email and mobile push. - Historical replay when you enable New-finding Notifications. - Analysis and remediation without queue delays. This work currently runs through queues and can be delayed by account capacity or worker backlog. - Automatic finding updates based on the full remediation PR lifecycle. A finding currently closes only when Dependabot reports it fixed or someone dismisses it. diff --git a/packages/kilo-docs/pages/getting-started/byok.md b/packages/kilo-docs/pages/getting-started/byok.md index d3915372cb..f4871f1899 100644 --- a/packages/kilo-docs/pages/getting-started/byok.md +++ b/packages/kilo-docs/pages/getting-started/byok.md @@ -24,6 +24,7 @@ Use your provider API key to route matching models through your account: - Anthropic - AWS Bedrock +- Azure Foundry (experimental) - DeepSeek - Fireworks - Google AI Studio @@ -67,7 +68,18 @@ These providers offer coding-focused subscriptions or dedicated endpoints. Bring ### AWS Bedrock configuration -AWS Bedrock requires credentials in a different format than other providers. Instead of a single API key, you must provide your AWS credentials as a JSON object: +AWS Bedrock requires JSON credentials. Use one of these two formats; don't mix fields from both. + +**Bedrock API key:** Generate a key in the AWS Bedrock console and use a region where the key and model are available. Replace the key before it expires. + +```json +{ + "apiKey": "...", + "region": "us-east-1" +} +``` + +**IAM credentials:** ```json { @@ -88,6 +100,32 @@ Your IAM user or role must have the following permissions: - `bedrock:InvokeModel` - `bedrock:InvokeModelWithResponseStream` +### Azure Foundry configuration + +Select **Azure Foundry (experimental)** and enter JSON credentials. Use `resourceName` for the subdomain of your endpoint, such as `my-resource` from `my-resource.openai.azure.com`: + +```json +{ + "apiKey": "...", + "resourceName": "my-resource" +} +``` + +If your deployment names differ from the gateway model IDs, add `modelMappings` to map each model to its Azure deployment: + +```json +{ + "apiKey": "...", + "resourceName": "my-resource", + "modelMappings": [ + { + "gatewayModelSlug": "openai/gpt-5.4-nano", + "customModelId": "my-gpt-5-4-nano-deployment" + } + ] +} +``` + ## How Bring Your Own Key works - When you use the **Kilo Gateway** provider, Kilo checks if there's a BYOK key for the selected model's provider. diff --git a/packages/kilo-docs/pages/getting-started/faq/account-and-integration.md b/packages/kilo-docs/pages/getting-started/faq/account-and-integration.md index d1c2f600fa..b0d23e2038 100644 --- a/packages/kilo-docs/pages/getting-started/faq/account-and-integration.md +++ b/packages/kilo-docs/pages/getting-started/faq/account-and-integration.md @@ -18,6 +18,16 @@ Your organization will become inaccessible. No charges will be applied. If you have any remaining credits in your organization, you can contact Support to request that they be moved to your personal account. +### How do I delete my account? + +Account deletion is permanent. Cancel active subscriptions before starting. + +1. Open the [profile page](https://app.kilo.ai/profile) and find **Danger Zone**. +2. Click **Delete account**, then **Send confirmation code**. +3. Enter the code sent to your account email and confirm deletion. + +You are signed out when deletion starts and receive an email when it completes. If your organization has disabled your personal account, self-service deletion is unavailable and **Danger Zone** is hidden. Contact your organization admin or Kilo support instead. + ## Integrations ### How do I unlink my GitHub account? diff --git a/packages/kilo-docs/pages/getting-started/index.md b/packages/kilo-docs/pages/getting-started/index.md index 1fc06f14b6..491f75daba 100644 --- a/packages/kilo-docs/pages/getting-started/index.md +++ b/packages/kilo-docs/pages/getting-started/index.md @@ -20,7 +20,7 @@ Your sessions sync across all of these, so you can start a task on your phone an ## What Kilo Can Do -- [**Code with AI**](/docs/code-with-ai) — Generate, refactor, and debug code through natural conversation. Use specialized modes (Code, Architect, Debug, Ask) or create your own. Get inline suggestions with Autocomplete. +- [**Code with AI**](/docs/code-with-ai) — Generate, refactor, and debug code through natural conversation. Use specialized agents (Code, Plan, Debug, Ask) or create your own. Get inline suggestions with Autocomplete. - [**Collaborate**](/docs/collaborate) — Share sessions, manage team settings, and track AI adoption across your organization. - [**Automate**](/docs/automate) — Set up AI-powered code reviews, triage agents, and auto-fixers that open new PRs based on issues. - [**Deploy & Secure**](/docs/deploy-secure) — Build and deploy apps directly from Kilo. Run security scans and manage issues with AI assistance. @@ -35,7 +35,7 @@ Your sessions sync across all of these, so you can start a task on your phone an **The easiest way to configure Kilo is to ask the agent.** Just tell the agent what you want — "add this MCP server", "disable OpenAI", "add my Ollama endpoint". The agent has a built-in skill for reading and updating your `kilo.jsonc` configuration. [Learn more](/docs/getting-started/settings#configuring-with-the-agent) {% /callout %} -New to AI coding assistants? Before learning what Kilo itself does, you can learn about agentic engineering at [path.kilo.ai](https://path.kilo.ai) +New to AI coding assistants? Before learning what Kilo itself does, you can learn about agentic engineering in [Agentic Engineering for Humans](https://github.com/Kilo-Org/agentic-path) Coming from Cursor or Windsurf? See our [migration guide](/docs/getting-started/migrating) diff --git a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md index 070fc59df5..c93646168e 100644 --- a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md +++ b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md @@ -59,18 +59,21 @@ The Auto Approve tab lists the following tool-specific permissions. Some tools a ## Runtime Permission Requests -When a tool is set to `"ask"`, Kilo pauses and displays a permission prompt with two options: +When a tool is set to `"ask"`, Kilo pauses and displays a permission prompt: | Option | Behavior | |---|---| | **Run** | Allow this specific invocation | -| **Deny** | Block this specific invocation | +| **Deny** | Reveal an optional feedback field | +| **Reject** | Block this specific invocation and send any feedback to the agent | + +In the feedback field, describe what the agent should change before it retries. Press `Enter` to post the rejection, `Shift+Enter` to add a newline, or `Escape` to cancel. Use the shield button in the prompt controls to toggle runtime auto-approve for permission prompts without opening Settings. When enabled, the shield is highlighted and pending permission prompts are approved automatically. The runtime state stays synced across the sidebar, open Kilo tabs, and Agent Manager session views. Expand **Manage Auto-Approve Rules** to add commands or patterns to your allowed or denied lists. These rules are then appended to the bottom of the approval rules in settings and the config file. -For the `agent_manager` tool, runtime approvals use the requested capability as the pattern: `worktree`, `local`, `overview`, or `prompt`. Prompting an existing managed session always requires an explicit `prompt` approval the first time, even when a broad Agent Manager allow rule already exists. +For the `agent_manager` tool, runtime approvals use the requested capability as the pattern: `worktree`, `local`, `overview`, `prompt`, `stop`, `move`, or `answer`. Prompting, stopping, moving, or answering a managed session requires its own explicit capability approval the first time, even when a broad Agent Manager allow rule already exists. ## MCP Tool Permissions @@ -265,7 +268,7 @@ When a tool is set to `"ask"`, Kilo pauses and displays a permission prompt. You |---|---| | **Allow once** | Allow this specific invocation only | | **Allow always** | Save an allow rule for the matching tool or pattern in your global config | -| **Reject** | Block this specific invocation | +| **Reject** | Block this specific invocation; you can add optional feedback that the agent uses to adjust before retrying | For shell commands, saved approvals are written under `permission.bash` and apply across CLI sessions. diff --git a/packages/kilo-docs/pages/index.tsx b/packages/kilo-docs/pages/index.tsx index 0134a571c5..cda1eedb3d 100644 --- a/packages/kilo-docs/pages/index.tsx +++ b/packages/kilo-docs/pages/index.tsx @@ -372,6 +372,20 @@ export default function HomePage() { +
+ 🐍 +
+ Kilo has been acquired by Anaconda + + Read the announcement + +
+
@@ -790,7 +804,7 @@ export default function HomePage() { .footer-grid { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(4, 1fr); gap: 2rem; } diff --git a/packages/kilo-docs/previous-docs-redirects.js b/packages/kilo-docs/previous-docs-redirects.js index b4cf440607..38ea6c5df8 100644 --- a/packages/kilo-docs/previous-docs-redirects.js +++ b/packages/kilo-docs/previous-docs-redirects.js @@ -265,7 +265,7 @@ module.exports = [ }, { source: "/docs/kiloclaw/tools", - destination: "/docs/kiloclaw/development-tools", + destination: "/docs/getting-started", basePath: false, permanent: true, }, @@ -1149,12 +1149,6 @@ module.exports = [ basePath: false, permanent: true, }, - { - source: "/docs/automate/kiloclaw/:path*", - destination: "/docs/kiloclaw/:path*", - basePath: false, - permanent: true, - }, { source: "/docs/contributing/architecture/vercel-ai-gateway", destination: "/docs/contributing/features", @@ -1169,38 +1163,56 @@ module.exports = [ }, // ============================================ - // KILOCLAW + // KILOCLAW (removed from public docs) // ============================================ - { - source: "/docs/kiloclaw/suggested-configuration", - destination: "/docs/kiloclaw/end-to-end", - basePath: false, - permanent: true, - }, - { - source: "/docs/kiloclaw/control-ui", - destination: "/docs/kiloclaw/control-ui/overview", - basePath: false, - permanent: true, - }, - { - source: "/docs/kiloclaw/pricing", - destination: "/docs/kiloclaw/faq/pricing", - basePath: false, - permanent: true, - }, - { - source: "/docs/kiloclaw/troubleshooting", - destination: "/docs/kiloclaw/troubleshooting/common-questions", - basePath: false, - permanent: true, - }, - { - source: "/docs/kiloclaw/version-pinning", - destination: "/docs/kiloclaw/control-ui/version-pinning", - basePath: false, - permanent: true, - }, + ...[ + "", + "/overview", + "/dashboard", + "/pre-installed-software", + "/end-to-end", + "/suggested-configuration", + "/control-ui", + "/control-ui/overview", + "/control-ui/changing-models", + "/control-ui/exec-approvals", + "/control-ui/version-pinning", + "/version-pinning", + "/chat-platforms", + "/chat-platforms/telegram", + "/chat-platforms/discord", + "/chat-platforms/slack", + "/development-tools", + "/development-tools/github", + "/development-tools/google", + "/development-tools/linear", + "/development-tools/composio", + "/tools", + "/tools/1password", + "/tools/brave-search", + "/tools/agentcard", + "/tools/other-tools", + "/triggers", + "/triggers/webhooks", + "/triggers/scheduled", + "/troubleshooting", + "/troubleshooting/common-questions", + "/troubleshooting/gateway-process", + "/troubleshooting/architecture", + "/troubleshooting/faq", + "/faq/general", + "/faq/pricing", + "/pricing", + ].flatMap((suffix) => + ["/docs/kiloclaw", "/docs/automate/kiloclaw"] + .filter((prefix) => prefix !== "/docs/kiloclaw" || suffix !== "/tools") + .map((prefix) => ({ + source: `${prefix}${suffix}`, + destination: "/docs/getting-started", + basePath: false, + permanent: true, + })), + ), { source: "/docs/code-with-ai/gastown/wasteland/troubleshooting", destination: "/docs/code-with-ai/gastown/wasteland", diff --git a/packages/kilo-docs/public/favicon/android-chrome-192x192.png b/packages/kilo-docs/public/favicon/android-chrome-192x192.png new file mode 100644 index 0000000000..bbc50f6fc9 Binary files /dev/null and b/packages/kilo-docs/public/favicon/android-chrome-192x192.png differ diff --git a/packages/kilo-docs/public/favicon/android-chrome-512x512.png b/packages/kilo-docs/public/favicon/android-chrome-512x512.png new file mode 100644 index 0000000000..b83ac83bda Binary files /dev/null and b/packages/kilo-docs/public/favicon/android-chrome-512x512.png differ diff --git a/packages/kilo-docs/public/favicon/apple-touch-icon.png b/packages/kilo-docs/public/favicon/apple-touch-icon.png new file mode 100644 index 0000000000..e6c4bbf999 Binary files /dev/null and b/packages/kilo-docs/public/favicon/apple-touch-icon.png differ diff --git a/packages/kilo-docs/public/globals.css b/packages/kilo-docs/public/globals.css index ecc0be3b3f..38d34308ec 100644 --- a/packages/kilo-docs/public/globals.css +++ b/packages/kilo-docs/public/globals.css @@ -4,7 +4,7 @@ @custom-variant dark (&:where(.dark, .dark *)); :root { - --top-nav-height: 141px; /* 105px nav + 36px banner */ + --top-nav-height: 105px; --border-color: #dce6e9; --bg-color: theme(colors.zinc.50); --bg-secondary: theme(colors.zinc.100); @@ -385,7 +385,7 @@ pre[class*="language-"] { @media (max-width: 768px) { :root { - --top-nav-height: 116px; /* 60px nav + 56px banner */ + --top-nav-height: 60px; } /* Hide desktop sidenav, show via mobile toggle */ diff --git a/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions-1.png b/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions-1.png new file mode 100644 index 0000000000..679265c5c5 Binary files /dev/null and b/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions-1.png differ diff --git a/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions-13.png b/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions-13.png new file mode 100644 index 0000000000..6998442dbc Binary files /dev/null and b/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions-13.png differ diff --git a/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions-14.png b/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions-14.png new file mode 100644 index 0000000000..0659869938 Binary files /dev/null and b/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions-14.png differ diff --git a/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions-4.png b/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions-4.png new file mode 100644 index 0000000000..aca74d783f Binary files /dev/null and b/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions-4.png differ diff --git a/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions-9.png b/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions-9.png new file mode 100644 index 0000000000..238cbd30c7 Binary files /dev/null and b/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions-9.png differ diff --git a/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions.png b/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions.png new file mode 100644 index 0000000000..ea4a7a426a Binary files /dev/null and b/packages/kilo-docs/public/img/auto-approving-actions/auto-approving-actions.png differ diff --git a/packages/kilo-docs/public/img/auto-cleanup/settings.png b/packages/kilo-docs/public/img/auto-cleanup/settings.png new file mode 100644 index 0000000000..b887ecf276 Binary files /dev/null and b/packages/kilo-docs/public/img/auto-cleanup/settings.png differ diff --git a/packages/kilo-docs/public/img/checkpoints/checkpoints-1.png b/packages/kilo-docs/public/img/checkpoints/checkpoints-1.png new file mode 100644 index 0000000000..4acd43178e Binary files /dev/null and b/packages/kilo-docs/public/img/checkpoints/checkpoints-1.png differ diff --git a/packages/kilo-docs/public/img/checkpoints/checkpoints-2.png b/packages/kilo-docs/public/img/checkpoints/checkpoints-2.png new file mode 100644 index 0000000000..63719a10f6 Binary files /dev/null and b/packages/kilo-docs/public/img/checkpoints/checkpoints-2.png differ diff --git a/packages/kilo-docs/public/img/checkpoints/checkpoints-3.png b/packages/kilo-docs/public/img/checkpoints/checkpoints-3.png new file mode 100644 index 0000000000..e212b6190a Binary files /dev/null and b/packages/kilo-docs/public/img/checkpoints/checkpoints-3.png differ diff --git a/packages/kilo-docs/public/img/checkpoints/checkpoints-4.png b/packages/kilo-docs/public/img/checkpoints/checkpoints-4.png new file mode 100644 index 0000000000..7c4fc3e78b Binary files /dev/null and b/packages/kilo-docs/public/img/checkpoints/checkpoints-4.png differ diff --git a/packages/kilo-docs/public/img/checkpoints/checkpoints-6.png b/packages/kilo-docs/public/img/checkpoints/checkpoints-6.png new file mode 100644 index 0000000000..3dd7ccb9c7 Binary files /dev/null and b/packages/kilo-docs/public/img/checkpoints/checkpoints-6.png differ diff --git a/packages/kilo-docs/public/img/checkpoints/checkpoints-7.png b/packages/kilo-docs/public/img/checkpoints/checkpoints-7.png new file mode 100644 index 0000000000..e9dc5ac45a Binary files /dev/null and b/packages/kilo-docs/public/img/checkpoints/checkpoints-7.png differ diff --git a/packages/kilo-docs/public/img/checkpoints/checkpoints-9.png b/packages/kilo-docs/public/img/checkpoints/checkpoints-9.png new file mode 100644 index 0000000000..b53afd3375 Binary files /dev/null and b/packages/kilo-docs/public/img/checkpoints/checkpoints-9.png differ diff --git a/packages/kilo-docs/public/img/checkpoints/checkpoints.png b/packages/kilo-docs/public/img/checkpoints/checkpoints.png new file mode 100644 index 0000000000..84a324b6ec Binary files /dev/null and b/packages/kilo-docs/public/img/checkpoints/checkpoints.png differ diff --git a/packages/kilo-docs/public/img/checkpoints/revert-to-here-button.png b/packages/kilo-docs/public/img/checkpoints/revert-to-here-button.png new file mode 100644 index 0000000000..1670d3017c Binary files /dev/null and b/packages/kilo-docs/public/img/checkpoints/revert-to-here-button.png differ diff --git a/packages/kilo-docs/public/img/code-actions/code-actions-1.png b/packages/kilo-docs/public/img/code-actions/code-actions-1.png new file mode 100644 index 0000000000..250e542952 Binary files /dev/null and b/packages/kilo-docs/public/img/code-actions/code-actions-1.png differ diff --git a/packages/kilo-docs/public/img/code-reviewer/review-mode.png b/packages/kilo-docs/public/img/code-reviewer/review-mode.png new file mode 100644 index 0000000000..c8b7e1ece7 Binary files /dev/null and b/packages/kilo-docs/public/img/code-reviewer/review-mode.png differ diff --git a/packages/kilo-docs/public/img/codebase-indexing/codebase-indexing.png b/packages/kilo-docs/public/img/codebase-indexing/codebase-indexing.png new file mode 100644 index 0000000000..0262d94186 Binary files /dev/null and b/packages/kilo-docs/public/img/codebase-indexing/codebase-indexing.png differ diff --git a/packages/kilo-docs/public/img/connect/github/github-bug.png b/packages/kilo-docs/public/img/connect/github/github-bug.png new file mode 100644 index 0000000000..1095aa4ab4 Binary files /dev/null and b/packages/kilo-docs/public/img/connect/github/github-bug.png differ diff --git a/packages/kilo-docs/public/img/connect/github/github-issue.png b/packages/kilo-docs/public/img/connect/github/github-issue.png new file mode 100644 index 0000000000..092ce869d2 Binary files /dev/null and b/packages/kilo-docs/public/img/connect/github/github-issue.png differ diff --git a/packages/kilo-docs/public/img/connect/github/github-review.png b/packages/kilo-docs/public/img/connect/github/github-review.png new file mode 100644 index 0000000000..cea6ea000c Binary files /dev/null and b/packages/kilo-docs/public/img/connect/github/github-review.png differ diff --git a/packages/kilo-docs/public/img/connect/linear/linear-fix-issue.png b/packages/kilo-docs/public/img/connect/linear/linear-fix-issue.png new file mode 100644 index 0000000000..b4862a0570 Binary files /dev/null and b/packages/kilo-docs/public/img/connect/linear/linear-fix-issue.png differ diff --git a/packages/kilo-docs/public/img/connect/linear/linear-multi-repo.png b/packages/kilo-docs/public/img/connect/linear/linear-multi-repo.png new file mode 100644 index 0000000000..1355570890 Binary files /dev/null and b/packages/kilo-docs/public/img/connect/linear/linear-multi-repo.png differ diff --git a/packages/kilo-docs/public/img/connect/linear/linear-understand-issue.png b/packages/kilo-docs/public/img/connect/linear/linear-understand-issue.png new file mode 100644 index 0000000000..ec6743a243 Binary files /dev/null and b/packages/kilo-docs/public/img/connect/linear/linear-understand-issue.png differ diff --git a/packages/kilo-docs/public/img/connecting-api-provider/connecting-api-provider-4.png b/packages/kilo-docs/public/img/connecting-api-provider/connecting-api-provider-4.png new file mode 100644 index 0000000000..a60622d16d Binary files /dev/null and b/packages/kilo-docs/public/img/connecting-api-provider/connecting-api-provider-4.png differ diff --git a/packages/kilo-docs/public/img/connecting-api-provider/connecting-api-provider-5.png b/packages/kilo-docs/public/img/connecting-api-provider/connecting-api-provider-5.png new file mode 100644 index 0000000000..09f170a862 Binary files /dev/null and b/packages/kilo-docs/public/img/connecting-api-provider/connecting-api-provider-5.png differ diff --git a/packages/kilo-docs/public/img/connecting-api-provider/connecting-api-provider-6.png b/packages/kilo-docs/public/img/connecting-api-provider/connecting-api-provider-6.png new file mode 100644 index 0000000000..8ac7e53032 Binary files /dev/null and b/packages/kilo-docs/public/img/connecting-api-provider/connecting-api-provider-6.png differ diff --git a/packages/kilo-docs/public/img/connecting-api-provider/connecting-api-provider-7.png b/packages/kilo-docs/public/img/connecting-api-provider/connecting-api-provider-7.png new file mode 100644 index 0000000000..62fdfbb849 Binary files /dev/null and b/packages/kilo-docs/public/img/connecting-api-provider/connecting-api-provider-7.png differ diff --git a/packages/kilo-docs/public/img/context-mentions/context-mentions-1.png b/packages/kilo-docs/public/img/context-mentions/context-mentions-1.png new file mode 100644 index 0000000000..9f8bdc4beb Binary files /dev/null and b/packages/kilo-docs/public/img/context-mentions/context-mentions-1.png differ diff --git a/packages/kilo-docs/public/img/context-mentions/context-mentions-2.png b/packages/kilo-docs/public/img/context-mentions/context-mentions-2.png new file mode 100644 index 0000000000..90ecbda70b Binary files /dev/null and b/packages/kilo-docs/public/img/context-mentions/context-mentions-2.png differ diff --git a/packages/kilo-docs/public/img/context-mentions/context-mentions-3.png b/packages/kilo-docs/public/img/context-mentions/context-mentions-3.png new file mode 100644 index 0000000000..20b2bfea46 Binary files /dev/null and b/packages/kilo-docs/public/img/context-mentions/context-mentions-3.png differ diff --git a/packages/kilo-docs/public/img/context-mentions/context-mentions-4.png b/packages/kilo-docs/public/img/context-mentions/context-mentions-4.png new file mode 100644 index 0000000000..321c750105 Binary files /dev/null and b/packages/kilo-docs/public/img/context-mentions/context-mentions-4.png differ diff --git a/packages/kilo-docs/public/img/context-mentions/context-mentions-5.png b/packages/kilo-docs/public/img/context-mentions/context-mentions-5.png new file mode 100644 index 0000000000..bd0367d149 Binary files /dev/null and b/packages/kilo-docs/public/img/context-mentions/context-mentions-5.png differ diff --git a/packages/kilo-docs/public/img/context-mentions/context-mentions-6.png b/packages/kilo-docs/public/img/context-mentions/context-mentions-6.png new file mode 100644 index 0000000000..4eb159a315 Binary files /dev/null and b/packages/kilo-docs/public/img/context-mentions/context-mentions-6.png differ diff --git a/packages/kilo-docs/public/img/context-mentions/context-mentions.png b/packages/kilo-docs/public/img/context-mentions/context-mentions.png new file mode 100644 index 0000000000..30afb6ccb8 Binary files /dev/null and b/packages/kilo-docs/public/img/context-mentions/context-mentions.png differ diff --git a/packages/kilo-docs/public/img/custom-instructions/custom-instructions-2.png b/packages/kilo-docs/public/img/custom-instructions/custom-instructions-2.png new file mode 100644 index 0000000000..6c92f3dedd Binary files /dev/null and b/packages/kilo-docs/public/img/custom-instructions/custom-instructions-2.png differ diff --git a/packages/kilo-docs/public/img/custom-instructions/custom-instructions-3.png b/packages/kilo-docs/public/img/custom-instructions/custom-instructions-3.png new file mode 100644 index 0000000000..1a8f573729 Binary files /dev/null and b/packages/kilo-docs/public/img/custom-instructions/custom-instructions-3.png differ diff --git a/packages/kilo-docs/public/img/custom-instructions/custom-instructions.png b/packages/kilo-docs/public/img/custom-instructions/custom-instructions.png new file mode 100644 index 0000000000..14bec6eb10 Binary files /dev/null and b/packages/kilo-docs/public/img/custom-instructions/custom-instructions.png differ diff --git a/packages/kilo-docs/public/img/custom-models/custom-provider-button.png b/packages/kilo-docs/public/img/custom-models/custom-provider-button.png new file mode 100644 index 0000000000..4fabb5481a Binary files /dev/null and b/packages/kilo-docs/public/img/custom-models/custom-provider-button.png differ diff --git a/packages/kilo-docs/public/img/custom-models/custom-provider-details.png b/packages/kilo-docs/public/img/custom-models/custom-provider-details.png new file mode 100644 index 0000000000..2a22661b9d Binary files /dev/null and b/packages/kilo-docs/public/img/custom-models/custom-provider-details.png differ diff --git a/packages/kilo-docs/public/img/custom-modes/custom-modes-2.png b/packages/kilo-docs/public/img/custom-modes/custom-modes-2.png new file mode 100644 index 0000000000..4fa120b78e Binary files /dev/null and b/packages/kilo-docs/public/img/custom-modes/custom-modes-2.png differ diff --git a/packages/kilo-docs/public/img/custom-modes/custom-modes.png b/packages/kilo-docs/public/img/custom-modes/custom-modes.png new file mode 100644 index 0000000000..a5ab7d21b8 Binary files /dev/null and b/packages/kilo-docs/public/img/custom-modes/custom-modes.png differ diff --git a/packages/kilo-docs/public/img/custom-rules/custom-rules.png b/packages/kilo-docs/public/img/custom-rules/custom-rules.png new file mode 100644 index 0000000000..7970dbfd62 Binary files /dev/null and b/packages/kilo-docs/public/img/custom-rules/custom-rules.png differ diff --git a/packages/kilo-docs/public/img/custom-rules/rules-ui.png b/packages/kilo-docs/public/img/custom-rules/rules-ui.png new file mode 100644 index 0000000000..655fc97c52 Binary files /dev/null and b/packages/kilo-docs/public/img/custom-rules/rules-ui.png differ diff --git a/packages/kilo-docs/public/img/enhance-prompt/after.png b/packages/kilo-docs/public/img/enhance-prompt/after.png new file mode 100644 index 0000000000..678e2a6a99 Binary files /dev/null and b/packages/kilo-docs/public/img/enhance-prompt/after.png differ diff --git a/packages/kilo-docs/public/img/enhance-prompt/before.png b/packages/kilo-docs/public/img/enhance-prompt/before.png new file mode 100644 index 0000000000..0cccf5935b Binary files /dev/null and b/packages/kilo-docs/public/img/enhance-prompt/before.png differ diff --git a/packages/kilo-docs/public/img/enhance-prompt/custom-enhance-profile.png b/packages/kilo-docs/public/img/enhance-prompt/custom-enhance-profile.png new file mode 100644 index 0000000000..112c9e2373 Binary files /dev/null and b/packages/kilo-docs/public/img/enhance-prompt/custom-enhance-profile.png differ diff --git a/packages/kilo-docs/public/img/enterprise-mcp-controls-org-user-install.png b/packages/kilo-docs/public/img/enterprise-mcp-controls-org-user-install.png new file mode 100644 index 0000000000..fc64472843 Binary files /dev/null and b/packages/kilo-docs/public/img/enterprise-mcp-controls-org-user-install.png differ diff --git a/packages/kilo-docs/public/img/enterprise-mcp-controls-today.png b/packages/kilo-docs/public/img/enterprise-mcp-controls-today.png new file mode 100644 index 0000000000..93e17a61c1 Binary files /dev/null and b/packages/kilo-docs/public/img/enterprise-mcp-controls-today.png differ diff --git a/packages/kilo-docs/public/img/enterprise-mcp-controls-with-ent-control.png b/packages/kilo-docs/public/img/enterprise-mcp-controls-with-ent-control.png new file mode 100644 index 0000000000..befb039104 Binary files /dev/null and b/packages/kilo-docs/public/img/enterprise-mcp-controls-with-ent-control.png differ diff --git a/packages/kilo-docs/public/img/faq/credits-environment-selector.png b/packages/kilo-docs/public/img/faq/credits-environment-selector.png new file mode 100644 index 0000000000..3de0a63c92 Binary files /dev/null and b/packages/kilo-docs/public/img/faq/credits-environment-selector.png differ diff --git a/packages/kilo-docs/public/img/fast-edits/fast-edits-1.png b/packages/kilo-docs/public/img/fast-edits/fast-edits-1.png new file mode 100644 index 0000000000..dc98d35677 Binary files /dev/null and b/packages/kilo-docs/public/img/fast-edits/fast-edits-1.png differ diff --git a/packages/kilo-docs/public/img/fast-edits/fast-edits-3.png b/packages/kilo-docs/public/img/fast-edits/fast-edits-3.png new file mode 100644 index 0000000000..ae5842a016 Binary files /dev/null and b/packages/kilo-docs/public/img/fast-edits/fast-edits-3.png differ diff --git a/packages/kilo-docs/public/img/fast-edits/fast-edits-4.png b/packages/kilo-docs/public/img/fast-edits/fast-edits-4.png new file mode 100644 index 0000000000..b6b26784f3 Binary files /dev/null and b/packages/kilo-docs/public/img/fast-edits/fast-edits-4.png differ diff --git a/packages/kilo-docs/public/img/fast-edits/fast-edits-5.png b/packages/kilo-docs/public/img/fast-edits/fast-edits-5.png new file mode 100644 index 0000000000..da54e439d5 Binary files /dev/null and b/packages/kilo-docs/public/img/fast-edits/fast-edits-5.png differ diff --git a/packages/kilo-docs/public/img/fast-edits/fast-edits.png b/packages/kilo-docs/public/img/fast-edits/fast-edits.png new file mode 100644 index 0000000000..036beb4d3b Binary files /dev/null and b/packages/kilo-docs/public/img/fast-edits/fast-edits.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-beads-page-detail.png b/packages/kilo-docs/public/img/gastown/gt-beads-page-detail.png new file mode 100644 index 0000000000..01722c3f1f Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-beads-page-detail.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-beads-page.png b/packages/kilo-docs/public/img/gastown/gt-beads-page.png new file mode 100644 index 0000000000..baed8ac1a1 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-beads-page.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-merge-queue-page-review-detail.png b/packages/kilo-docs/public/img/gastown/gt-merge-queue-page-review-detail.png new file mode 100644 index 0000000000..349af417c8 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-merge-queue-page-review-detail.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-merge-queue-page.png b/packages/kilo-docs/public/img/gastown/gt-merge-queue-page.png new file mode 100644 index 0000000000..368f8ac325 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-merge-queue-page.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-new-rig.png b/packages/kilo-docs/public/img/gastown/gt-new-rig.png new file mode 100644 index 0000000000..7cb3444e53 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-new-rig.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-new-town-onboarding.png b/packages/kilo-docs/public/img/gastown/gt-new-town-onboarding.png new file mode 100644 index 0000000000..d1a3e6ac8f Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-new-town-onboarding.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-bead-in-review.png b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-bead-in-review.png new file mode 100644 index 0000000000..210e5aaa67 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-bead-in-review.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-in-progress.png b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-in-progress.png new file mode 100644 index 0000000000..8880690dc1 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-in-progress.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-review-bead-detail.png b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-review-bead-detail.png new file mode 100644 index 0000000000..43dae48328 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-review-bead-detail.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy-detail.png b/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy-detail.png new file mode 100644 index 0000000000..ec185145d0 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy-detail.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy.png b/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy.png new file mode 100644 index 0000000000..fdb9f9251f Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-town-overview.png b/packages/kilo-docs/public/img/gastown/gt-town-overview.png new file mode 100644 index 0000000000..12306817e5 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-town-overview.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/gt-bead-with-wasteland-link.png b/packages/kilo-docs/public/img/gastown/wasteland/gt-bead-with-wasteland-link.png new file mode 100644 index 0000000000..f3f25d6fb0 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/gt-bead-with-wasteland-link.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/gt-claim-detail-drawer.png b/packages/kilo-docs/public/img/gastown/wasteland/gt-claim-detail-drawer.png new file mode 100644 index 0000000000..d74355c16b Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/gt-claim-detail-drawer.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/gt-mayor-claiming.png b/packages/kilo-docs/public/img/gastown/wasteland/gt-mayor-claiming.png new file mode 100644 index 0000000000..0f4f4b90fb Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/gt-mayor-claiming.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-settings.png b/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-settings.png new file mode 100644 index 0000000000..76b44beef8 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-settings.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/wl-admin-review-inbox.png b/packages/kilo-docs/public/img/gastown/wasteland/wl-admin-review-inbox.png new file mode 100644 index 0000000000..12306817e5 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/wl-admin-review-inbox.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/wl-claim-drawer.png b/packages/kilo-docs/public/img/gastown/wasteland/wl-claim-drawer.png new file mode 100644 index 0000000000..394655e951 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/wl-claim-drawer.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/wl-evidence-submitted.png b/packages/kilo-docs/public/img/gastown/wasteland/wl-evidence-submitted.png new file mode 100644 index 0000000000..0d25c69624 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/wl-evidence-submitted.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/wl-post-form.png b/packages/kilo-docs/public/img/gastown/wasteland/wl-post-form.png new file mode 100644 index 0000000000..2112b4c327 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/wl-post-form.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/wl-wanted-board.png b/packages/kilo-docs/public/img/gastown/wasteland/wl-wanted-board.png new file mode 100644 index 0000000000..6021861936 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/wl-wanted-board.png differ diff --git a/packages/kilo-docs/public/img/git-commit-generation/git-commit-1.png b/packages/kilo-docs/public/img/git-commit-generation/git-commit-1.png new file mode 100644 index 0000000000..8d6d72d0cd Binary files /dev/null and b/packages/kilo-docs/public/img/git-commit-generation/git-commit-1.png differ diff --git a/packages/kilo-docs/public/img/git-commit-generation/git-commit-2.png b/packages/kilo-docs/public/img/git-commit-generation/git-commit-2.png new file mode 100644 index 0000000000..de4cdfd1b9 Binary files /dev/null and b/packages/kilo-docs/public/img/git-commit-generation/git-commit-2.png differ diff --git a/packages/kilo-docs/public/img/installing-vsix.png b/packages/kilo-docs/public/img/installing-vsix.png new file mode 100644 index 0000000000..47a2e7f1bb Binary files /dev/null and b/packages/kilo-docs/public/img/installing-vsix.png differ diff --git a/packages/kilo-docs/public/img/mistral-setup/01-open-kilo-code-settings.png b/packages/kilo-docs/public/img/mistral-setup/01-open-kilo-code-settings.png new file mode 100644 index 0000000000..6cdb9f0792 Binary files /dev/null and b/packages/kilo-docs/public/img/mistral-setup/01-open-kilo-code-settings.png differ diff --git a/packages/kilo-docs/public/img/mistral-setup/02-add-configuration-profile.png b/packages/kilo-docs/public/img/mistral-setup/02-add-configuration-profile.png new file mode 100644 index 0000000000..179493cb08 Binary files /dev/null and b/packages/kilo-docs/public/img/mistral-setup/02-add-configuration-profile.png differ diff --git a/packages/kilo-docs/public/img/mistral-setup/03-name-your-profile.png b/packages/kilo-docs/public/img/mistral-setup/03-name-your-profile.png new file mode 100644 index 0000000000..d6f2900efa Binary files /dev/null and b/packages/kilo-docs/public/img/mistral-setup/03-name-your-profile.png differ diff --git a/packages/kilo-docs/public/img/mistral-setup/04-select-mistral-provider.png b/packages/kilo-docs/public/img/mistral-setup/04-select-mistral-provider.png new file mode 100644 index 0000000000..267804d48e Binary files /dev/null and b/packages/kilo-docs/public/img/mistral-setup/04-select-mistral-provider.png differ diff --git a/packages/kilo-docs/public/img/mistral-setup/05-get-api-key.png b/packages/kilo-docs/public/img/mistral-setup/05-get-api-key.png new file mode 100644 index 0000000000..3d08b0d2dc Binary files /dev/null and b/packages/kilo-docs/public/img/mistral-setup/05-get-api-key.png differ diff --git a/packages/kilo-docs/public/img/mistral-setup/06-navigate-to-codestral.png b/packages/kilo-docs/public/img/mistral-setup/06-navigate-to-codestral.png new file mode 100644 index 0000000000..2c611a65af Binary files /dev/null and b/packages/kilo-docs/public/img/mistral-setup/06-navigate-to-codestral.png differ diff --git a/packages/kilo-docs/public/img/mistral-setup/07-confirm-key-generation.png b/packages/kilo-docs/public/img/mistral-setup/07-confirm-key-generation.png new file mode 100644 index 0000000000..32dde09aa6 Binary files /dev/null and b/packages/kilo-docs/public/img/mistral-setup/07-confirm-key-generation.png differ diff --git a/packages/kilo-docs/public/img/mistral-setup/08-copy-api-key.png b/packages/kilo-docs/public/img/mistral-setup/08-copy-api-key.png new file mode 100644 index 0000000000..a8ac8fe139 Binary files /dev/null and b/packages/kilo-docs/public/img/mistral-setup/08-copy-api-key.png differ diff --git a/packages/kilo-docs/public/img/mistral-setup/09-paste-api-key.png b/packages/kilo-docs/public/img/mistral-setup/09-paste-api-key.png new file mode 100644 index 0000000000..6a6083cb97 Binary files /dev/null and b/packages/kilo-docs/public/img/mistral-setup/09-paste-api-key.png differ diff --git a/packages/kilo-docs/public/img/mistral-setup/10-save-settings.png b/packages/kilo-docs/public/img/mistral-setup/10-save-settings.png new file mode 100644 index 0000000000..d6f6402900 Binary files /dev/null and b/packages/kilo-docs/public/img/mistral-setup/10-save-settings.png differ diff --git a/packages/kilo-docs/public/img/mobile-apps/kiloclaw-chat.webp b/packages/kilo-docs/public/img/mobile-apps/kiloclaw-chat.webp deleted file mode 100644 index 1df99e23e2..0000000000 Binary files a/packages/kilo-docs/public/img/mobile-apps/kiloclaw-chat.webp and /dev/null differ diff --git a/packages/kilo-docs/public/img/modes/modes-1.png b/packages/kilo-docs/public/img/modes/modes-1.png new file mode 100644 index 0000000000..1ef9117a6a Binary files /dev/null and b/packages/kilo-docs/public/img/modes/modes-1.png differ diff --git a/packages/kilo-docs/public/img/modes/modes-2.png b/packages/kilo-docs/public/img/modes/modes-2.png new file mode 100644 index 0000000000..e10b0a6fa3 Binary files /dev/null and b/packages/kilo-docs/public/img/modes/modes-2.png differ diff --git a/packages/kilo-docs/public/img/modes/modes.png b/packages/kilo-docs/public/img/modes/modes.png new file mode 100644 index 0000000000..b994aa8e67 Binary files /dev/null and b/packages/kilo-docs/public/img/modes/modes.png differ diff --git a/packages/kilo-docs/public/img/move-to-secondary.png b/packages/kilo-docs/public/img/move-to-secondary.png new file mode 100644 index 0000000000..9347e5804b Binary files /dev/null and b/packages/kilo-docs/public/img/move-to-secondary.png differ diff --git a/packages/kilo-docs/public/img/settings-management/settings-management.png b/packages/kilo-docs/public/img/settings-management/settings-management.png new file mode 100644 index 0000000000..e19dfbb1e8 Binary files /dev/null and b/packages/kilo-docs/public/img/settings-management/settings-management.png differ diff --git a/packages/kilo-docs/public/img/slash-commands/workflows.png b/packages/kilo-docs/public/img/slash-commands/workflows.png new file mode 100644 index 0000000000..ab3307331c Binary files /dev/null and b/packages/kilo-docs/public/img/slash-commands/workflows.png differ diff --git a/packages/kilo-docs/public/img/suggested-responses/suggested-responses-1.png b/packages/kilo-docs/public/img/suggested-responses/suggested-responses-1.png new file mode 100644 index 0000000000..76fe29f536 Binary files /dev/null and b/packages/kilo-docs/public/img/suggested-responses/suggested-responses-1.png differ diff --git a/packages/kilo-docs/public/img/suggested-responses/suggested-responses.png b/packages/kilo-docs/public/img/suggested-responses/suggested-responses.png new file mode 100644 index 0000000000..e9e8def917 Binary files /dev/null and b/packages/kilo-docs/public/img/suggested-responses/suggested-responses.png differ diff --git a/packages/kilo-docs/public/img/task-todo-list/complete.png b/packages/kilo-docs/public/img/task-todo-list/complete.png new file mode 100644 index 0000000000..3946ce7ed3 Binary files /dev/null and b/packages/kilo-docs/public/img/task-todo-list/complete.png differ diff --git a/packages/kilo-docs/public/img/task-todo-list/in-progress.png b/packages/kilo-docs/public/img/task-todo-list/in-progress.png new file mode 100644 index 0000000000..e1e7dbfc2a Binary files /dev/null and b/packages/kilo-docs/public/img/task-todo-list/in-progress.png differ diff --git a/packages/kilo-docs/public/img/task-todo-list/not-started.png b/packages/kilo-docs/public/img/task-todo-list/not-started.png new file mode 100644 index 0000000000..41001cda86 Binary files /dev/null and b/packages/kilo-docs/public/img/task-todo-list/not-started.png differ diff --git a/packages/kilo-docs/public/img/task-todo-list/task-header.png b/packages/kilo-docs/public/img/task-todo-list/task-header.png new file mode 100644 index 0000000000..96119b3b8c Binary files /dev/null and b/packages/kilo-docs/public/img/task-todo-list/task-header.png differ diff --git a/packages/kilo-docs/public/img/task-todo-list/task-todo-list-1.png b/packages/kilo-docs/public/img/task-todo-list/task-todo-list-1.png new file mode 100644 index 0000000000..a5600c999b Binary files /dev/null and b/packages/kilo-docs/public/img/task-todo-list/task-todo-list-1.png differ diff --git a/packages/kilo-docs/public/img/team-management/invite-member.png b/packages/kilo-docs/public/img/team-management/invite-member.png new file mode 100644 index 0000000000..976e37b0c5 Binary files /dev/null and b/packages/kilo-docs/public/img/team-management/invite-member.png differ diff --git a/packages/kilo-docs/public/img/teams/create-team.png b/packages/kilo-docs/public/img/teams/create-team.png new file mode 100644 index 0000000000..a62bf881b6 Binary files /dev/null and b/packages/kilo-docs/public/img/teams/create-team.png differ diff --git a/packages/kilo-docs/public/img/teams/custom_modes.png b/packages/kilo-docs/public/img/teams/custom_modes.png new file mode 100644 index 0000000000..be09f1a962 Binary files /dev/null and b/packages/kilo-docs/public/img/teams/custom_modes.png differ diff --git a/packages/kilo-docs/public/img/teams/dashboard.png b/packages/kilo-docs/public/img/teams/dashboard.png new file mode 100644 index 0000000000..82451b866d Binary files /dev/null and b/packages/kilo-docs/public/img/teams/dashboard.png differ diff --git a/packages/kilo-docs/public/img/teams/invite-member.png b/packages/kilo-docs/public/img/teams/invite-member.png new file mode 100644 index 0000000000..976e37b0c5 Binary files /dev/null and b/packages/kilo-docs/public/img/teams/invite-member.png differ diff --git a/packages/kilo-docs/public/img/teams/org_credits.png b/packages/kilo-docs/public/img/teams/org_credits.png new file mode 100644 index 0000000000..589157d235 Binary files /dev/null and b/packages/kilo-docs/public/img/teams/org_credits.png differ diff --git a/packages/kilo-docs/public/img/teams/subscribe.png b/packages/kilo-docs/public/img/teams/subscribe.png new file mode 100644 index 0000000000..04c317cfe1 Binary files /dev/null and b/packages/kilo-docs/public/img/teams/subscribe.png differ diff --git a/packages/kilo-docs/public/img/teams/usage-details.png b/packages/kilo-docs/public/img/teams/usage-details.png new file mode 100644 index 0000000000..d7cd9a9d85 Binary files /dev/null and b/packages/kilo-docs/public/img/teams/usage-details.png differ diff --git a/packages/kilo-docs/public/img/the-chat-interface/the-chat-interface-1.png b/packages/kilo-docs/public/img/the-chat-interface/the-chat-interface-1.png new file mode 100644 index 0000000000..d4550828b1 Binary files /dev/null and b/packages/kilo-docs/public/img/the-chat-interface/the-chat-interface-1.png differ diff --git a/packages/kilo-docs/public/img/typing-your-requests/typing-your-requests.png b/packages/kilo-docs/public/img/typing-your-requests/typing-your-requests.png new file mode 100644 index 0000000000..14b8bda031 Binary files /dev/null and b/packages/kilo-docs/public/img/typing-your-requests/typing-your-requests.png differ diff --git a/packages/kilo-docs/public/img/using-mcp-in-kilo-code/mcp-installed-config.png b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/mcp-installed-config.png new file mode 100644 index 0000000000..e98345bcc2 Binary files /dev/null and b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/mcp-installed-config.png differ diff --git a/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-1.png b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-1.png new file mode 100644 index 0000000000..e602c103de Binary files /dev/null and b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-1.png differ diff --git a/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-2.png b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-2.png new file mode 100644 index 0000000000..59f80a2a46 Binary files /dev/null and b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-2.png differ diff --git a/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-3.png b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-3.png new file mode 100644 index 0000000000..064c185e7b Binary files /dev/null and b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-3.png differ diff --git a/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-4.png b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-4.png new file mode 100644 index 0000000000..dff3532ecd Binary files /dev/null and b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-4.png differ diff --git a/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-5.png b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-5.png new file mode 100644 index 0000000000..a995f04cd1 Binary files /dev/null and b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-5.png differ diff --git a/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-6.png b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-6.png new file mode 100644 index 0000000000..5f643b267f Binary files /dev/null and b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-6.png differ diff --git a/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-7.png b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-7.png new file mode 100644 index 0000000000..3b3a75a062 Binary files /dev/null and b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-7.png differ diff --git a/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-8.png b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-8.png new file mode 100644 index 0000000000..b25949e178 Binary files /dev/null and b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-8.png differ diff --git a/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-9.png b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-9.png new file mode 100644 index 0000000000..1e5ea319ec Binary files /dev/null and b/packages/kilo-docs/public/img/using-mcp-in-kilo-code/using-mcp-in-kilo-code-9.png differ diff --git a/packages/kilo-docs/public/img/your-first-task/your-first-task-6.png b/packages/kilo-docs/public/img/your-first-task/your-first-task-6.png new file mode 100644 index 0000000000..da0d95da06 Binary files /dev/null and b/packages/kilo-docs/public/img/your-first-task/your-first-task-6.png differ diff --git a/packages/kilo-docs/public/img/your-first-task/your-first-task-7.png b/packages/kilo-docs/public/img/your-first-task/your-first-task-7.png new file mode 100644 index 0000000000..50a2ec805d Binary files /dev/null and b/packages/kilo-docs/public/img/your-first-task/your-first-task-7.png differ diff --git a/packages/kilo-docs/public/img/your-first-task/your-first-task-8.png b/packages/kilo-docs/public/img/your-first-task/your-first-task-8.png new file mode 100644 index 0000000000..a0c9f62faa Binary files /dev/null and b/packages/kilo-docs/public/img/your-first-task/your-first-task-8.png differ diff --git a/packages/kilo-docs/public/img/your-first-task/your-first-task.png b/packages/kilo-docs/public/img/your-first-task/your-first-task.png new file mode 100644 index 0000000000..0cfd3c38d5 Binary files /dev/null and b/packages/kilo-docs/public/img/your-first-task/your-first-task.png differ diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 40d62bf749..503ce7fa73 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -15,11 +15,6 @@ - -- - - - - - @@ -146,9 +141,6 @@ - -- - - - - diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 179f3d11da..34371b0d45 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -23,8 +23,7 @@ "./edit-prompt": "./src/edit-prompt.ts", "./provider-usage": "./src/provider-usage.ts", "./event-service": "./src/event-service/client.ts", - "./tui": "./src/tui.ts", - "./claw": "./src/claw/index.ts" + "./tui": "./src/tui.ts" }, "files": [ "dist" diff --git a/packages/kilo-gateway/src/api/constants.ts b/packages/kilo-gateway/src/api/constants.ts index ee9ea880e2..fc70215d68 100644 --- a/packages/kilo-gateway/src/api/constants.ts +++ b/packages/kilo-gateway/src/api/constants.ts @@ -12,15 +12,6 @@ export const DEFAULT_KILO_API_URL = "https://api.kilo.ai" /** Base URL for Kilo API - can be overridden by KILO_API_URL env var */ export const KILO_API_BASE = process.env[ENV_KILO_API_URL] || DEFAULT_KILO_API_URL -/** Environment variable for custom Kilo Chat URL */ -export const KILO_CHAT_URL_ENV = "KILO_CHAT_URL" - -/** Default Kilo Chat URL (REST endpoint for messages, conversations, etc.) */ -export const KILO_DEFAULT_CHAT_URL = "https://chat.kiloapps.io" - -/** Base URL for Kilo Chat - can be overridden by KILO_CHAT_URL env var */ -export const KILO_CHAT_URL = process.env[KILO_CHAT_URL_ENV] || KILO_DEFAULT_CHAT_URL - /** Environment variable for custom Event Service URL */ export const KILO_EVENT_SERVICE_URL_ENV = "EVENT_SERVICE_URL" diff --git a/packages/kilo-gateway/src/index.ts b/packages/kilo-gateway/src/index.ts index ecf12c3a7f..b495f2af65 100644 --- a/packages/kilo-gateway/src/index.ts +++ b/packages/kilo-gateway/src/index.ts @@ -94,13 +94,10 @@ export { GatewayError, UnauthorizedError, getOrganizationId, - getClawChatCredentials, - getClawStatus, getCloudSessions, getNotifications, getProfile, getToken, - normalizeClawStatus, setOrganization, } from "./server/handlers.js" @@ -139,7 +136,6 @@ export { ENV_KILO_API_URL, DEFAULT_KILO_API_URL, KILO_API_BASE, - KILO_CHAT_URL, KILO_EVENT_SERVICE_URL, KILO_OPENROUTER_BASE, POLL_INTERVAL_MS, diff --git a/packages/kilo-gateway/src/server/handlers.ts b/packages/kilo-gateway/src/server/handlers.ts index 167bfbf4db..1633cc5946 100644 --- a/packages/kilo-gateway/src/server/handlers.ts +++ b/packages/kilo-gateway/src/server/handlers.ts @@ -2,7 +2,7 @@ import { fetchBalance, fetchProfile } from "../api/profile.js" import { fetchKiloPassState } from "../api/kilo-pass.js" import { fetchKilocodeNotifications } from "../api/notifications.js" import { clearModesCache } from "../api/modes.js" -import { HEADER_ORGANIZATIONID, KILO_API_BASE, KILO_CHAT_URL, KILO_EVENT_SERVICE_URL } from "../api/constants.js" +import { KILO_API_BASE } from "../api/constants.js" import type { KilocodeBalance, KilocodeProfile, KiloPassState } from "../types.js" import { buildKiloHeaders } from "../headers.js" @@ -18,13 +18,6 @@ export interface KiloProfileResult { currentOrgId: string | null } -export interface ClawChatCredentials { - token: string - expiresAt: string - kiloChatUrl: string - eventServiceUrl: string -} - export interface AuthStore { get(provider: string): Promise set(provider: string, auth: Extract): Promise @@ -106,53 +99,6 @@ export async function setOrganization(deps: OrganizationDeps, organizationId: st return true } -export async function getClawStatus(auth: AuthStore) { - const info = await auth.get("kilo") - const token = getToken(info) - if (!token) throw new UnauthorizedError("No valid token found") - - const headers: Record = { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - } - const org = getOrganizationId(info) - if (org) headers[HEADER_ORGANIZATIONID] = org - - const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/status`, { headers }) - if (!response.ok) throw new GatewayError(await response.text(), response.status) - return normalizeClawStatus(await response.json()) -} - -function normalizeTime(value: unknown) { - if (typeof value === "number" && Number.isFinite(value)) return new Date(value).toISOString() - return value -} - -export function normalizeClawStatus(input: unknown) { - if (!input || typeof input !== "object" || Array.isArray(input)) return input - - const data = input as Record - return { - ...data, - ...("lastStartedAt" in data ? { lastStartedAt: normalizeTime(data.lastStartedAt) } : {}), - ...("lastStoppedAt" in data ? { lastStoppedAt: normalizeTime(data.lastStoppedAt) } : {}), - } -} - -export async function getClawChatCredentials(auth: AuthStore): Promise { - const info = await auth.get("kilo") - const token = getToken(info) - if (!token) throw new UnauthorizedError("No valid token found") - - const expires = info?.type === "oauth" ? info.expires : Date.now() + 365 * 24 * 60 * 60 * 1000 - return { - token, - expiresAt: new Date(expires).toISOString(), - kiloChatUrl: KILO_CHAT_URL, - eventServiceUrl: KILO_EVENT_SERVICE_URL, - } -} - export async function getCloudSessions(token: string, input: CloudSessionsInput) { const query: Record = {} if (input.cursor) query.cursor = input.cursor diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 403cf71bab..fcd02ecd59 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -8,13 +8,7 @@ import { fetchKilocodeNotifications, KilocodeNotificationSchema } from "../api/notifications.js" import { fetchKiloImageModels } from "../api/models.js" import { fetchOrganizationModes, clearModesCache } from "../api/modes.js" -import { - KILO_API_BASE, - KILO_CHAT_URL, - KILO_EVENT_SERVICE_URL, - HEADER_FEATURE, - HEADER_ORGANIZATIONID, -} from "../api/constants.js" +import { KILO_API_BASE, HEADER_FEATURE, HEADER_ORGANIZATIONID } from "../api/constants.js" import { buildKiloHeaders } from "../headers.js" import type { ImportDeps, DrizzleDb } from "../cloud-sessions.js" import { @@ -28,8 +22,6 @@ import { createFimHandler } from "./fim.js" import { GatewayError, UnauthorizedError, - getClawChatCredentials, - getClawStatus, getCloudSessions, getNotifications, getProfile, @@ -457,67 +449,6 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { }) }, ) - .post( - "/audio/transcriptions", - describeRoute({ - summary: "Speech to text transcription", - description: "Proxy an audio transcription request to the Kilo Gateway", - operationId: "kilo.audio.transcriptions", - responses: { - 200: { - description: "Transcription response", - content: { - "application/json": { - schema: resolver(TranscriptionResponse), - }, - }, - }, - ...errors(400, 401), - }, - }), - validator( - "json", - z.object({ - model: z.string(), - input_audio: z.object({ - data: z.string(), - format: z.string(), - }), - language: z.string().optional(), - prompt: z.string().optional(), - temperature: z.number().optional(), - }), - ), - async (c: any) => { - const proxy = await getProxyAuth() - if (!proxy.auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) - - if (!proxy.token) return c.json({ error: "No valid token found" }, 401) - - const body = c.req.valid("json") - const headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${proxy.token}`, - ...buildKiloHeaders(undefined, { kilocodeOrganizationId: proxy.organizationId }), - [HEADER_FEATURE]: "vscode-extension", - } - - const response = await fetch(`${KILO_API_BASE}/api/gateway/v1/audio/transcriptions`, { - method: "POST", - headers, - signal: c.req.raw.signal, - body: JSON.stringify(body), - }) - - const text = await response.text() - return new Response(text, { - status: response.status, - headers: { - "Content-Type": response.headers.get("Content-Type") ?? "application/json", - }, - }) - }, - ) .get( "/models/images", describeRoute({ @@ -724,107 +655,6 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { } }, ) - .get( - "/claw/status", - describeRoute({ - summary: "Get KiloClaw instance status", - description: "Fetch the user's KiloClaw instance status via the KiloClaw worker", - operationId: "kilo.claw.status", - responses: { - 200: { - description: "Instance status", - content: { - "application/json": { - schema: resolver( - z.object({ - // `recovering` and `restoring` are transitional states the - // worker reports while it brings an instance back online - // after an unexpected stop or a snapshot restore — see - // cloud `services/kiloclaw/src/index.ts` and the - // `PlatformStatusResponse` type in - // cloud/apps/web/src/lib/kiloclaw/types.ts. Keeping them in - // the enum so the SDK types stay accurate. - status: z - .enum([ - "provisioned", - "starting", - "restarting", - "recovering", - "running", - "stopped", - "destroying", - "restoring", - ]) - .nullable(), - sandboxId: z.string().optional(), - flyRegion: z.string().optional(), - machineSize: z.object({ cpus: z.number(), memory_mb: z.number() }).optional(), - openclawVersion: z.string().nullable().optional(), - lastStartedAt: z.string().nullable().optional(), - lastStoppedAt: z.string().nullable().optional(), - channelCount: z.number().optional(), - secretCount: z.number().optional(), - userId: z.string().optional(), - botName: z.string().nullable().optional(), - }), - ), - }, - }, - }, - ...errors(401, 502), - }, - }), - async (c: any) => { - try { - return c.json(await getClawStatus(Auth)) - } catch (err: any) { - if (err instanceof GatewayError) { - return c.json({ error: `KiloClaw request failed: ${err.status} ${err.message}` }, err.status as any) - } - console.error("[Kilo Gateway] claw/status: error", err?.message ?? err) - return c.json({ error: "Failed to reach KiloClaw" }, 502) - } - }, - ) - .get( - "/claw/chat-credentials", - describeRoute({ - summary: "Get KiloClaw chat credentials", - description: - "Returns the bearer token and endpoint URLs the client uses to talk to the Kilo Chat worker " + - "and the Event Service. The bearer is the user's existing long-lived Kilo JWT — kilo-chat and " + - "event-service both verify it directly with NEXTAUTH_SECRET, so no separate token mint is needed.", - operationId: "kilo.claw.chatCredentials", - responses: { - 200: { - description: "Kilo Chat credentials or null", - content: { - "application/json": { - schema: resolver( - z - .object({ - token: z.string(), - expiresAt: z.string(), - kiloChatUrl: z.string(), - eventServiceUrl: z.string(), - }) - .nullable(), - ), - }, - }, - }, - ...errors(401), - }, - }), - async (c: any) => { - try { - return c.json(await getClawChatCredentials(Auth)) - } catch (err) { - if (!(err instanceof UnauthorizedError)) throw err - return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) - } - }, - ) .get( "/cloud-sessions", describeRoute({ diff --git a/packages/kilo-jetbrains/.run/VSCode - Isolated Clean.run.xml b/packages/kilo-jetbrains/.run/VSCode - Isolated Clean.run.xml new file mode 100644 index 0000000000..9293936daa --- /dev/null +++ b/packages/kilo-jetbrains/.run/VSCode - Isolated Clean.run.xml @@ -0,0 +1,12 @@ + + + + + +