Merge branch 'main' into docs/daoxe-provider-guide

This commit is contained in:
Joshua Lambert
2026-07-31 13:04:12 -04:00
committed by GitHub
3018 changed files with 232456 additions and 98864 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Apply the Agent Manager base branch picker selection to the active diff immediately. Changing the base branch now refreshes the diff against the new base instead of keeping the previous comparison until the scope or session changed.
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add a scope selector and base branch picker to the Agent Manager diff review. The side panel and full-screen review now let you switch between Branch, Staged, Unstaged, and Session scopes for the selected worktree, and the Branch scope's base branch can be overridden from a picker next to it. Branch stays the default, so existing review behavior is unchanged.
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Inherit the current model and reasoning variant when Agent Manager starts sessions without explicit overrides.
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add an experimental multi-project foundation for Agent Manager behind the Multi-Project Agent Manager toggle in Kilo Settings > Experimental. A persistent project registry catalogs additional Git repositories across restarts while the current workspace repository stays the pinned default project, and all repository-bound services (state, worktrees, setup scripts, stale tracking, polling) are now owned by immutable per-project contexts that can be activated, expanded, and switched through new project protocol messages. Every expanded project runs its own git stats and PR pollers, so all visible project accordions show live data at the same time; switching projects keeps previously visited projects expanded and restores the exact worktree or session that was open there. Sessions created anywhere (sidebar, another window, the CLI) appear in the owning project's list immediately, and open tabs stay private to the project they were opened in. Single-project behavior is unchanged when the experiment is disabled.
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Make the Agent Manager diff review follow the sidebar selection instead of a single session. Switching session tabs inside a worktree no longer refetches the Branch, Staged, and Unstaged scopes, the Session scope now swaps to the active session's changes on tab switch, and the Local tab gains the Session scope so sessions running in the workspace can be reviewed on their own. The Session scope shows a notice when snapshots are disabled instead of a blank list, worktrees without an open session now still show their branch diff, and the Apply dialog lists the worktree's changes again.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix Agent Manager embedded terminals not opening in the side panel or as a tab.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Make Cmd/Ctrl+/ toggle the Agent Manager terminal even when the webview keybinding forwarding drops the key while the prompt input is focused, and stop it from triggering the Agent Manager terminal while the Kilo sidebar is focused.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Match the Agent Manager terminal shortcut fallback to the platform modifier (Cmd on macOS, Ctrl elsewhere) and consume the extension echo once per keypress so unrelated invocations are no longer swallowed.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix the Agent Manager detail pane blanking when an unassigned session is selected. Clicking a session that belongs to no worktree now renders the chat and read-only banner again instead of an empty content area, and live terminal tabs stay mounted across the switch.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep the Agent Manager terminal cursor visible on the bottom row.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Run Agent Manager project scripts in the terminal selected by the existing toolbar dropdown. Agent Manager panel uses the named side terminal, while VS Code terminal retains the integrated task flow.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Preserve dynamic tool properties when removing unsupported regex lookarounds.
-6
View File
@@ -1,6 +0,0 @@
---
"@kilocode/cli": patch
"@kilocode/kilo-indexing": patch
---
Wait for the primary codebase index before indexing a linked worktree, preventing large worktrees from consuming excessive CPU during startup.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": minor
---
Add in-chat search to the VS Code sidebar and editor tabs. Click the search icon in the session header to find text across the current conversation, with match case, whole word, and regular expression options, then step through highlighted matches with the next/previous controls.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Make session model usage easier to scan with collapsible summary rows and aligned steps and cost columns.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"@kilocode/kilo-telemetry": patch
---
Reduce CLI startup time by deferring Kilo-specific module loading until commands actually run, caching the telemetry profile lookup across invocations, and uploading telemetry in the background so process exit is not delayed by a network round trip
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Fix settings snapping back to their previous value after being cleared to "Not set" when multiple config files exist (e.g. both `kilo.json` and `kilo.jsonc`)
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Mark Kilo Console as deprecated and direct users to supported session and settings workflows.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Allow users to enable web search for models from all providers through Kilo configuration, VS Code settings, and Kilo Console settings.
@@ -1,6 +0,0 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---
Speed up VS Code settings saves by draining pending prompts and disposing worktree instances concurrently.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Resolve AWS Bedrock credentials from SSO profiles in packaged CLI builds.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Use the correct `filePath` argument name in the Gemini system prompt.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Fix Gemma 4 models failing with "thinkingLevel not supported" when using Google AI Studio.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Install the latest stable CLI release when newer non-CLI or prerelease releases exist.
-7
View File
@@ -1,7 +0,0 @@
---
"@kilocode/cli": patch
---
Fix `kilo upgrade` for curl installs resolving the wrong latest version
The upgrade command's version resolution for curl-detected installations used GitHub's `/releases/latest` endpoint, which now returns JetBrains plugin releases (e.g. `jetbrains/v7.0.4`) instead of the latest CLI release. This caused `kilo upgrade` to fail for curl installs. Version resolution now uses the npm `latest` dist-tag, matching the install script fix.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Keep RC installations up to date when a newer stable CLI release is published.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-memory": patch
---
Accept memory digest responses that include additional model-generated fields.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Report Bash commands terminated by a signal with the conventional 128 + signum exit code (e.g. 139 for SIGSEGV) instead of hanging until the command timeout.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Persist MCP server enable and disable changes from VS Code settings across window reloads.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Show the Kilo Gateway rate-limit message when login has too many pending authorization requests.
-6
View File
@@ -1,6 +0,0 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---
Show troubleshooting and migration guidance when Google Gemini rejects API credentials.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Prevent Gemini requests from failing when MCP tool schemas contain `required` fields without matching object properties.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Allow GPT-5.6 models to use tools whose JSON schemas contain regex lookarounds.
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Block environment and out-of-project file substitutions in project markdown configuration.
+13
View File
@@ -0,0 +1,13 @@
---
"@kilocode/cli": patch
---
Non-interactive `kilo run` no longer reports success for runs that did not complete. A plain
headless run (neither `--auto` nor `--dangerously-skip-permissions`) in which the CLI
auto-rejected at least one permission ask now exits 1 with a stderr diagnostic naming the cause,
and a run whose session errors mid-stream now prints that diagnostic to stderr under
`--format json` as well (previously the JSON branch swallowed it). Runs that complete their turn
with no auto-rejected permission still exit 0. Under `--format json` the auto-reject path adds a
new `error` event to the stream; existing event shapes are unchanged. The same exit-1 rule applies
to a plain non-interactive `--attach` run that auto-rejects an ask (that run was equally crippled);
interactive mode is untouched.
-6
View File
@@ -1,6 +0,0 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Hide gpt-5.5-pro from the model picker when using ChatGPT OAuth login, since Codex rejects it with HTTP 400.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Return keyboard focus to the JetBrains prompt after clicking inline session dialog actions.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Stop orphaned Kilo CLI processes when JetBrains IDEs close, including binaries that ignore graceful shutdown.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Refresh the JetBrains prompt input chrome when switching IDE themes.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Polish JetBrains prompt focus and copy toolbar positioning.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show rollback/redo progress inline (on the message and redo controls) with a cancel action instead of a full-screen loading overlay.
-6
View File
@@ -1,6 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
"kilo-code": patch
---
Support rollback and redo controls in JetBrains sessions and clarify when reverted changes can be redone.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Clarify in JetBrains rollback that only the conversation was reverted when snapshots are disabled.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Improve JetBrains session change tracking: show the files each assistant turn modified with expandable per-file diffs, open inline and branch diffs in a refreshable diff viewer, and surface branch changes in the session header.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Polish JetBrains session message toolbar alignment, rollback icon, and copy tooltips.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Increase spacing before non-initial user prompts in the JetBrains session transcript.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Load expanded diff previews consistently when opening a review.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": minor
---
Open multiple same-repository sessions as tabs from Kilo sidebar and editor-tab chats.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Navigate long conversations from a compact prompt rail that loads earlier history as you scroll.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix the `@` "Past chats" picker in Agent Manager showing only the current session's directory. It now lists previous sessions across the whole worktree family — the local workspace and every Agent Manager worktree — each labeled with its worktree name, matching the Agent Manager session search. Any listed session can be attached as context, including chats from other worktrees of the same repository.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add Persian (Farsi) as a UI language, including right-to-left layout. Contributed by Babak Safabahar.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Allow clearing a nested project setting when no project config file exists yet.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Show effective permission defaults and clarify external-directory approvals in VS Code.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Open an embedded terminal automatically when switching to a worktree without one.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Stop explicitly directing GPT and Codex models to delegate tasks to subagents.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Show the provider's actual error message when an OpenAI or Azure Responses API stream fails (for example an upstream rate limit) instead of a generic retry notice.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Include the underlying reason in search execution failures instead of showing a bare "ripgrep execution failed" message.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Show sandbox controls only after sandboxing is enabled in settings.
@@ -1,7 +0,0 @@
---
"kilo-code": minor
"@kilocode/cli": minor
"@kilocode/sdk": minor
---
Support configuring network destinations that sandboxed tools can reach while network access is otherwise restricted.
-6
View File
@@ -1,6 +0,0 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Shut down the headless `kilo serve` process automatically when the editor client that launched it exits without a clean signal, preventing orphaned CLI processes.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Make Agent Manager panel terminals behave like session tabs: right-click Close and Close Others, arrow-key tab navigation, overflow scrolling with edge fades, and stable tab widths while closing. The new-terminal button now sits directly next to the last terminal tab instead of the far edge of the panel.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Support executing shell commands embedded in skill files. Commands written as `` !`command` `` in a SKILL.md run and their output is inlined into the skill. Only trusted skills can run commands and `KILO_DISABLE_SKILL_SHELL` disables the behavior; when the model loads a skill, the commands are shown in a single up-front approval before running.
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Show skill shell commands in the permission prompt. When a skill runs embedded shell commands, the approval dialog now lists every command with Allow / Reject.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Run Agent Manager worktree setup scripts in the terminal selected by the toolbar dropdown. Agent Manager panel shows live setup output in a named `Setup` side tab that reveals itself while provisioning and hides again on success unless you engaged with the panel, while VS Code terminal retains the integrated task flow.
-6
View File
@@ -1,6 +0,0 @@
---
"@kilocode/cli": patch
"@kilocode/kilo-ui": patch
---
Show detailed GPT-5.6 reasoning summaries and avoid expandable blank panels when a provider returns only a summary title.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-telemetry": patch
---
Include the host operating system name, version, and architecture in telemetry events.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": minor
---
Add a "Browse files..." option to the @ mention dropdown in the VS Code extension prompt input. Selecting it opens a native file picker and mentions the chosen file, so you can point Kilo Code at files outside the current workspace. Files outside the workspace are not auto-attached; Kilo Code reads them on request through the normal Read tool, respecting your file access permissions.
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Start Agent Manager local sessions on Windows when workspace drive-letter casing differs.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Retry transient locked-file errors (EPERM/EACCES/EBUSY) on Windows when atomically saving config and other files. Background plugin installs and Windows Defender/indexer can briefly hold the temp file during the rename step, which previously surfaced as a 500 error. A short backoff now retries the rename so config writes succeed without surfacing the contention.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show a write-aware cache hit rate in the VS Code session usage bar.
+4
View File
@@ -35,3 +35,7 @@ packages/kilo-i18n/src/de.ts linguist-generated=false
# Auto-generated CLI reference docs
packages/kilo-docs/markdoc/partials/cli-commands-table.md linguist-generated=true
packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md linguist-generated=true
# Upstream generated migration artifacts
packages/core/migration/**/snapshot.json linguist-generated
packages/core/src/database/migration.gen.ts linguist-generated
+2 -4
View File
@@ -1,5 +1,3 @@
# web + desktop packages
packages/app/ @adamdotdevin
packages/tauri/ @adamdotdevin
packages/desktop/src-tauri/ @brendonovich
packages/desktop/ @adamdotdevin
packages/app/ @Hona @Brendonovich
packages/desktop/ @Hona @Brendonovich
+180
View File
@@ -0,0 +1,180 @@
// kilocode_change - new file
/**
* Collects PRs merged to the source repos since the watermark, applies a
* deterministic pre-filter, and writes docs-sync-out/digest.json for the LLM
* triage pass.
*
* Pre-filter drops (triage never sees these):
* - PRs labeled auto-docs (this bot's own rolling PRs)
* - chore/test/ci/build/docs/style/refactor conventional titles
* - PRs touching only docs/non-product paths
*
* Revert PRs (conventional `revert(...):` and GitHub-native `Revert "..."` titles)
* are intercepted as signals, never added to the digest; affected digest entries
* gain `reverted_by` so triage/edit can skip them.
*
* Bot-authored PRs are kept: release/dependency bots ship user-facing
* changes too, and the label + docs-only guards above prevent loops.
*/
import fs from "node:fs"
import { api, appendOutput, appendSummary, listPrFiles, searchIssues } from "./lib.mjs"
import { revertTitleKind, parseRevertTargets, applyRevertAnnotations, unannotatedRevertSignals } from "./reverts.mjs"
const SOURCE_REPOS = ["Kilo-Org/cloud", "Kilo-Org/kilocode"]
const OUT_DIR = "docs-sync-out"
const BODY_LIMIT = 2000
const SLIM_BODY_LIMIT = 300
const PATCH_LIMIT = 8000
const FILE_LIMIT = 30
// Revert PRs (conventional AND GitHub-native) are intercepted below BEFORE this filter; the "revert" alternative here is unreachable and kept only to minimize diff.
const DROP_TITLE = /^(chore|test|ci|build|docs|style|refactor|revert)(\(.+\))?!?:/i
const DOCS_ONLY_PATH = /^(packages\/kilo-docs\/|\.github\/docs-sync\/|docs-sync-out\/|docs\/|[^/]+\.md$)/
function argSince() {
const i = process.argv.indexOf("--since")
const v = i >= 0 ? process.argv[i + 1] : null
if (!v || Number.isNaN(new Date(v).getTime())) {
throw new Error("usage: collect.mjs --since <ISO date>")
}
return new Date(v)
}
async function mergedPrs(fullRepo, since) {
const query = `repo:${fullRepo} is:pr is:merged merged:>=${since.toISOString()}`
return searchIssues(query)
}
const since = argSince()
console.log(`collecting PRs merged since ${since.toISOString()}`)
const digest = []
const dropped = { label: 0, title: 0, docs_only: 0, fetch_error: 0, revert: 0 }
const reverts = []
for (const fullRepo of SOURCE_REPOS) {
const prs = await mergedPrs(fullRepo, since)
console.log(`${fullRepo}: ${prs.length} merged PRs in window`)
for (const item of prs) {
const author = item.user?.login ?? ""
if ((item.labels ?? []).some((l) => l.name === "auto-docs")) {
dropped.label++
continue
}
if (revertTitleKind(item.title ?? "")) {
try {
const pr = await api(`/repos/${fullRepo}/pulls/${item.number}`)
const targets = parseRevertTargets(pr.body ?? "", fullRepo)
reverts.push({ url: pr.html_url, merged_at: pr.merged_at, targets })
if (targets.length === 0) {
console.warn(`::warning::revert PR ${fullRepo}#${item.number} has no parseable targets`)
}
dropped.revert++
} catch (err) {
// One dead revert PR must not abort the run; its targets just go unannotated.
console.warn(`::warning::skipping revert ${fullRepo}#${item.number}: ${err.message}`)
dropped.fetch_error++
}
continue
}
if (DROP_TITLE.test(item.title ?? "")) {
dropped.title++
continue
}
const number = item.number
let pr
let files
try {
pr = await api(`/repos/${fullRepo}/pulls/${number}`)
files = await listPrFiles(fullRepo, number)
} catch (err) {
// Isolate per-PR failures: one dead PR must not abort the whole run.
console.warn(`::warning::skipping ${fullRepo}#${number}: ${err.message}`)
dropped.fetch_error++
continue
}
// listPrFiles caps at 300 files; a truncated list can't support the
// docs-only classification, so keep such PRs and record the true total.
const truncated = files.length >= 300
if (!truncated && files.length > 0 && files.every((f) => DOCS_ONLY_PATH.test(f.filename))) {
dropped.docs_only++
continue
}
let patch = ""
for (const f of files) {
if (!f.patch) continue
const chunk = `--- ${f.filename}\n${f.patch}\n`
if (patch.length + chunk.length > PATCH_LIMIT) {
patch += "\n... (diff truncated) ...\n"
break
}
patch += chunk
}
digest.push({
repo: fullRepo,
number,
title: pr.title,
url: pr.html_url,
author,
merged_at: pr.merged_at,
labels: (pr.labels ?? []).map((l) => l.name),
body: (pr.body ?? "").slice(0, BODY_LIMIT),
files: files.slice(0, FILE_LIMIT).map((f) => `${f.status} ${f.filename} (+${f.additions}/-${f.deletions})`),
files_total: pr.changed_files ?? files.length,
patch_excerpt: patch,
})
}
}
const applied = applyRevertAnnotations(digest, reverts)
const unannotated = unannotatedRevertSignals(reverts, applied)
digest.sort((a, b) => new Date(a.merged_at) - new Date(b.merged_at))
fs.mkdirSync(OUT_DIR, { recursive: true })
// Full digest (bodies + patch excerpts) is filtered down to docs-worthy PRs
// for the edit pass; the slim digest keeps the triage pass context small.
fs.writeFileSync(`${OUT_DIR}/digest-full.json`, JSON.stringify(digest, null, 2))
const slim = digest.map(({ patch_excerpt, body, ...rest }) => ({
...rest,
body: body.slice(0, SLIM_BODY_LIMIT),
}))
fs.writeFileSync(`${OUT_DIR}/digest.json`, JSON.stringify(slim, null, 2))
console.log(`kept ${digest.length} PRs, dropped:`, dropped)
appendOutput("count", digest.length)
appendOutput("digest", `${OUT_DIR}/digest.json`)
const summaryLines = [
"### docs-sync collect",
"",
`- window: since \`${since.toISOString()}\``,
`- kept: **${digest.length}** PRs`,
`- dropped: ${dropped.label} auto-docs, ${dropped.title} title filter, ${dropped.docs_only} docs-only, ${dropped.fetch_error} fetch errors, ${dropped.revert} reverts intercepted`,
"",
...digest.map((d) => `- [${d.repo}#${d.number}](${d.url}) ${d.title}`),
]
if (applied.length > 0) {
summaryLines.push("", "**revert annotations:**", ...applied.map(([target, reverter]) => `- ${target} — reverted by ${reverter}`))
}
if (unannotated.missed.length > 0 || unannotated.unparsed.length > 0) {
summaryLines.push("", "**revert targets with no in-window annotation:**")
for (const m of unannotated.missed) {
summaryLines.push(`- ${m.url} — unannotated targets: ${m.targets.join(", ")}`)
}
for (const url of unannotated.unparsed) {
summaryLines.push(`- ${url} (no parseable targets)`)
}
}
if (unannotated.chains.length > 0) {
summaryLines.push("", "**revert chains (not annotated):**")
for (const c of unannotated.chains) {
summaryLines.push(`- ${c.url}${c.targets.length > 0 ? ` (targets: ${c.targets.join(", ")})` : ""}`)
}
}
appendSummary(summaryLines.join("\n"))
+27
View File
@@ -0,0 +1,27 @@
You are the Kilo Code documentation bot. You update the public product documentation in `packages/kilo-docs` (a Markdoc/Next.js site served at kilo.ai/docs) so it reflects recently merged PRs. You are handling one batch of PRs; the batch files and your output file are named at the end of these instructions.
Before writing anything:
1. Read `packages/kilo-docs/AGENTS.md` and `packages/kilo-docs/STYLE_GUIDE.md` and follow them exactly: Markdoc custom tags, the `/docs` prefix in image paths, navigation files under `lib/nav/`, redirect rules, and the generated-screenshot policy.
2. Read the attached batch files: the full-details file (PR title, body, file list, `patch_excerpt` diffs) and the triage file (docs-worthiness verdicts, target sections, priorities).
3. Verify facts against the current source tree — for Kilo-Org/kilocode PRs. This checkout reflects current kilocode main: before documenting a command, flag, setting, default, or behavior from a kilocode PR, confirm it exists in the current source. Existence alone is not enough: defaults, whether an option is required or optional, and on-by-default behavior must also match the current tree — a symbol that still exists as opt-in does not justify documenting default-on behavior. When the merged diff and the current tree disagree, the current tree wins — the change may have been reverted or superseded; skip it and record why. For Kilo-Org/cloud PRs the source is not in this checkout: rely on the PR diff and body, and on the `reverted_by` field below. Reading any file in the checkout for verification is expected; the hard rule against touching anything outside `packages/kilo-docs/` applies to writing only.
For each PR in the batch, in priority order, first decide whether it needs documentation at all — skipping is a first-class outcome. For each one that does:
- Find the most relevant existing docs page(s) and make minimal, precise updates in the style of the surrounding content.
- Create a new page only when no existing page fits; then add it to the matching nav file in `packages/kilo-docs/lib/nav/`.
- Document only behavior that is actually present in the merged diff and still present in the product now (step 3 above: when they disagree, the current state wins). If the PR body or diff shows the feature is behind a flag or otherwise not user-visible yet, skip it and record why.
- If a PR turns out not to need documentation, skip it and record why. Trust evidence over the triage verdict.
- A batch PR with a `reverted_by` field was reverted by that PR; skip it unless there is clear evidence the change is present now — for kilocode PRs verify it in the current source tree (re-land); for cloud PRs require explicit re-land evidence in the entry itself.
- Skipping is a normal outcome: a batch where every PR is skipped is a valid result. Never write docs just to have something to show.
Hard rules:
- Only create or modify files under `packages/kilo-docs/`. Never touch code, tests, config, images, or anything outside that directory.
- Never remove or rename pages. Never document unreleased behavior. Never copy internal PR discussion into the docs; write user-facing documentation.
- Do not run git commands and do not commit anything; automation handles git.
- Keep the change small and precise. Do not rewrite sections that are already accurate.
When finished, write the summary JSON file named in the batch specifics below: a JSON array with exactly one entry per batch PR, consumed by automation (this file is never committed). Use `action` values like `updated <path>`, `created <path>`, or `skipped`. Example:
[{"pr": 123, "url": "https://github.com/Kilo-Org/kilocode/pull/123", "action": "updated pages/code-with-ai/platforms/cli.md", "reason": "documented --variant flag"}, {"pr": 124, "url": "https://github.com/Kilo-Org/kilocode/pull/124", "action": "skipped", "reason": "feature behind unreleased flag"}]
+203
View File
@@ -0,0 +1,203 @@
// kilocode_change - new file
/**
* Runs the LLM edit pass over docs-sync-out/worthy.json in batches.
*
* Batching bounds each `kilo run` context (a replay window can yield dozens
* of docs-worthy PRs with large diffs). Each batch gets its own CLI session
* and writes its own summary file; results are merged into
* docs-sync-out/edit-summary.json. A batch that fails or is deferred by the
* 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).
* Budgets: EDIT_BUDGET_MINUTES (default 50), EDIT_BATCH_TIMEOUT_MINUTES (default 15).
* Test hook: DOCS_SYNC_BACKOFF_MS replaces every retry wait when set.
*/
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs"
const BATCH_SIZE = 5
const ATTEMPTS = 3
const OUT_DIR = "docs-sync-out"
export const SUMMARY_FILE = ".docs-sync-summary.json"
const HERE = path.dirname(fileURLToPath(import.meta.url))
const basePrompt = fs.readFileSync(path.join(HERE, "edit-prompt.md"), "utf8")
const model = process.env.EDIT_MODEL
if (!model) throw new Error("EDIT_MODEL is required")
const EDIT_BUDGET_MINUTES = Number(process.env.EDIT_BUDGET_MINUTES) || 50
const EDIT_BATCH_TIMEOUT_MINUTES = Number(process.env.EDIT_BATCH_TIMEOUT_MINUTES) || 15
const BATCH_TIMEOUT_MS = EDIT_BATCH_TIMEOUT_MINUTES * 60 * 1000
const worthy = JSON.parse(fs.readFileSync(`${OUT_DIR}/worthy.json`, "utf8"))
const triage = JSON.parse(fs.readFileSync(`${OUT_DIR}/triage.json`, "utf8"))
const priority = new Map(triage.map((e) => [e.url, e]))
const ordered = [...worthy].sort((a, b) => {
const rank = { high: 0, medium: 1, low: 2 }
return (rank[priority.get(a.url)?.priority] ?? 1) - (rank[priority.get(b.url)?.priority] ?? 1)
})
/** @type {Map<string, string>} url → pending cause for failed/deferred batches */
const pendingCauses = new Map()
function formatCause(result) {
const bits = []
if (result.timedOut) bits.push("timed out")
if (result.exitCode !== null && result.exitCode !== undefined) bits.push(`exit ${result.exitCode}`)
if (result.stderrTail) bits.push(result.stderrTail.replaceAll("\n", " ").slice(0, 200))
return bits.join("; ") || "no diagnostic"
}
function editBatch(batch, index, budgetDeadline) {
const batchFile = `${OUT_DIR}/edit-batch-${index}.json`
const triageFile = `${OUT_DIR}/edit-batch-triage-${index}.json`
const summaryFile = `${OUT_DIR}/edit-summary-${index}.json`
fs.writeFileSync(batchFile, JSON.stringify(batch, null, 2))
fs.writeFileSync(
triageFile,
JSON.stringify(
batch.map((d) => priority.get(d.url)).filter(Boolean),
null,
2,
),
)
const prompt = `${basePrompt}
Batch specifics for this run: the PRs to handle are in the attached ${batchFile} (full details) and ${triageFile} (triage verdicts). Handle ONLY the PRs in these batch files. When finished, write your per-PR results in the summary JSON format described above to the file \`${summaryFile}\` (path relative to the repository root).`
let lastCause = "edit pass failed"
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
const left = remainingMs(budgetDeadline)
if (left < BATCH_TIMEOUT_MS) {
lastCause = `edit budget exhausted before batch ${index} attempt ${attempt} (${Math.ceil(left / 1000)}s left, need ${EDIT_BATCH_TIMEOUT_MINUTES}m)`
console.warn(
`batch ${index}: stopping retries — remaining budget cannot fit another ${EDIT_BATCH_TIMEOUT_MINUTES}m attempt`,
)
break
}
// Headless `kilo run` auto-rejects every permission ask; without --auto the
// agent cannot run shell commands. SECURITY: --auto grants unrestricted bash
// to an agent steered by external PR content. Hardening deferred: a scoped
// 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, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile],
timeoutMs: Math.min(BATCH_TIMEOUT_MS, left),
streamStdout: true,
label: `edit batch ${index} attempt ${attempt}`,
})
if (fs.existsSync(summaryFile)) return true
// Tolerate the agent dropping the docs-sync-out/ prefix.
const alt = path.basename(summaryFile)
if (fs.existsSync(alt)) {
fs.renameSync(alt, summaryFile)
return true
}
// Exit 0 is not success: missing summary is a failure logged WITH the
// captured stderrTail and exit code on every attempt.
const cause = formatCause(result)
lastCause = `edit batch ${index}: ${cause}`
console.warn(
`batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced` +
` (exit ${result.exitCode}${result.timedOut ? ", timed out" : ""})` +
(result.stderrTail ? `\nstderr tail:\n${result.stderrTail}` : "\nstderr tail: (empty)"),
)
if (attempt < ATTEMPTS) {
const wait = backoffMsForAttempt(attempt)
// Skip the wait when the remaining budget cannot fit another attempt.
const afterWait = remainingMs(budgetDeadline) - wait
if (wait > 0 && afterWait >= BATCH_TIMEOUT_MS) {
console.warn(`batch ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`)
sleepSync(wait)
} else if (wait > 0) {
console.warn(
`batch ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`,
)
}
}
}
console.warn(`::warning::edit batch ${index} failed after up to ${ATTEMPTS} attempts; ${batch.length} PRs pending`)
for (const d of batch) pendingCauses.set(d.url, lastCause)
return false
}
const batches = []
for (let i = 0; i < ordered.length; i += BATCH_SIZE) {
batches.push(ordered.slice(i, i + BATCH_SIZE))
}
console.log(`editing docs for ${ordered.length} PRs in ${batches.length} batches of up to ${BATCH_SIZE}`)
const budgetDeadline = deadline(EDIT_BUDGET_MINUTES)
let deferredFrom = -1
for (let i = 0; i < batches.length; i++) {
const left = remainingMs(budgetDeadline)
if (left < BATCH_TIMEOUT_MS) {
deferredFrom = i
const deferredPrs = batches.slice(i).reduce((n, b) => n + b.length, 0)
console.warn(
`stopping edit pass before batch ${i}: remaining budget (${Math.ceil(left / 1000)}s) cannot fit a ${EDIT_BATCH_TIMEOUT_MINUTES}m batch; deferring ${deferredPrs} PRs`,
)
const cause = `edit budget exhausted before batch ${i} (${Math.ceil(left / 1000)}s left)`
for (let j = i; j < batches.length; j++) {
for (const d of batches[j]) pendingCauses.set(d.url, cause)
}
break
}
editBatch(batches[i], i, budgetDeadline)
}
if (deferredFrom >= 0) {
console.warn(
`edit pass deferred ${batches.slice(deferredFrom).reduce((n, b) => n + b.length, 0)} PRs due to wall-clock budget`,
)
}
// Merge batch summaries. Coverage: every worthy PR gets an entry so the PR
// body accounts for it; failed/deferred batches show up as pending (not skipped).
const merged = []
const seen = new Set()
for (let i = 0; i < batches.length; i++) {
const file = `${OUT_DIR}/edit-summary-${i}.json`
let entries = []
try {
entries = JSON.parse(fs.readFileSync(file, "utf8"))
} catch {
continue
}
for (const e of entries) {
const url = String(e?.url ?? "")
if (!url.startsWith("http") || seen.has(url)) continue
seen.add(url)
merged.push({
pr: Number(e.pr) || 0,
url,
action: String(e.action ?? "skipped"),
reason: String(e.reason ?? ""),
})
}
}
for (const d of ordered) {
if (seen.has(d.url)) continue
const cause = pendingCauses.get(d.url) || "edit pass failed or timed out for this PR"
merged.push({ pr: d.number, url: d.url, action: "pending", reason: cause })
}
// upsert-pr.mjs consumes the merged summary from the repo root; the file is
// removed there before committing so it never lands in the docs PR.
fs.writeFileSync(SUMMARY_FILE, JSON.stringify(merged, null, 2))
const changed = merged.filter((e) => e.action !== "skipped" && e.action !== "pending").length
const skipped = merged.filter((e) => e.action === "skipped").length
const pending = merged.filter((e) => e.action === "pending").length
console.log(`edit pass complete: ${changed} changed, ${skipped} skipped, ${pending} pending`)
+76
View File
@@ -0,0 +1,76 @@
// kilocode_change - new file
/**
* Extracts and validates the triage JSON array from raw LLM stdout.
* Usage: extract-json.mjs <raw-input-file> <output-file>
* Exit 0 on success, 1 on any failure. Also exports parseTriageEntries for
* the chunked triage runner.
*/
import fs from "node:fs"
import { pathToFileURL } from "node:url"
/** Returns validated triage entries, or null when extraction fails. */
export function parseTriageEntries(raw) {
// `kilo run` prints the assistant message twice (streaming render + final
// summary), so stdout can hold the same array back-to-back. Try each "["
// from the right and return the first slice that parses — i.e. the last
// (most recent) valid array in the output.
const end = raw.lastIndexOf("]")
if (end < 0) return null
const starts = []
for (let i = 0; i <= end; i++) {
if (raw[i] === "[") starts.push(i)
}
for (let s = starts.length - 1; s >= 0; s--) {
let parsed
try {
parsed = JSON.parse(raw.slice(starts[s], end + 1))
} catch {
continue
}
if (!Array.isArray(parsed)) continue
const entries = validate(parsed)
if (entries) return entries
}
return null
}
function validate(parsed) {
const entries = []
for (const e of parsed) {
const pr = Number(e?.pr)
const url = String(e?.url ?? "")
if (!Number.isInteger(pr) || !url.startsWith("http")) continue
entries.push({
pr,
url,
docs_worthy: e.docs_worthy === true,
reason: String(e.reason ?? ""),
target_sections: Array.isArray(e.target_sections) ? e.target_sections.map(String) : [],
priority: ["high", "medium", "low"].includes(e.priority) ? e.priority : "medium",
})
}
return entries.length > 0 ? entries : null
}
function main() {
const [, , inputPath, outputPath] = process.argv
if (!inputPath || !outputPath) {
console.error("usage: extract-json.mjs <raw-input-file> <output-file>")
process.exit(1)
}
const entries = parseTriageEntries(fs.readFileSync(inputPath, "utf8"))
if (!entries) {
console.error("no valid triage JSON array found in input")
process.exit(1)
}
fs.writeFileSync(outputPath, JSON.stringify(entries, null, 2))
console.log(`extracted ${entries.length} triage entries (${entries.filter((e) => e.docs_worthy).length} docs-worthy)`)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main()
}
+24
View File
@@ -0,0 +1,24 @@
// kilocode_change - new file
/**
* Filters the full digest down to PRs the triage pass marked docs-worthy.
* Usage: filter-worthy.mjs <digest-full.json> <triage.json> <output.json>
* The edit pass consumes the output so its context stays small.
*/
import fs from "node:fs"
const [, , digestPath, triagePath, outputPath] = process.argv
if (!digestPath || !triagePath || !outputPath) {
console.error("usage: filter-worthy.mjs <digest-full.json> <triage.json> <output.json>")
process.exit(1)
}
const digest = JSON.parse(fs.readFileSync(digestPath, "utf8"))
const triage = JSON.parse(fs.readFileSync(triagePath, "utf8"))
const worthy = new Set(triage.filter((e) => e.docs_worthy).map((e) => e.url))
const out = digest.filter((d) => worthy.has(d.url))
fs.writeFileSync(outputPath, JSON.stringify(out, null, 2))
console.log(`${out.length} of ${digest.length} digest entries are docs-worthy`)
+266
View File
@@ -0,0 +1,266 @@
// kilocode_change - new file
/**
* Shared helpers for the docs-sync bot scripts. Dependency-free (Node 20+
* global fetch) so the workflow does not rely on runner images shipping the
* gh CLI.
*/
import { spawnSync } from "node:child_process"
import fs from "node:fs"
const API = "https://api.github.com"
const MAX_RETRIES = 3
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")
return t
}
export function repo() {
const r = process.env.GITHUB_REPOSITORY
if (!r) throw new Error("GITHUB_REPOSITORY is required")
return r
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
export async function api(path, { method = "GET", body } = {}) {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
let res
try {
res = await fetch(`${API}${path}`, {
method,
headers: {
authorization: `Bearer ${token()}`,
accept: "application/vnd.github+json",
"x-github-api-version": "2022-11-28",
"user-agent": "kilo-docs-sync-bot",
},
body: body === undefined ? undefined : JSON.stringify(body),
})
} catch (err) {
if (attempt < MAX_RETRIES) {
console.warn(`network error (${err.message}), retrying in ${5 * attempt}s`)
await sleep(5000 * attempt)
continue
}
throw err
}
if (res.status === 403) {
const text = await res.text()
if (text.includes("rate limit") && attempt < MAX_RETRIES) {
const retryAfter = Number(res.headers.get("retry-after")) || 30
console.warn(`rate limited, retrying in ${retryAfter}s`)
await sleep(retryAfter * 1000)
continue
}
const err = new Error(`${method} ${path} -> 403: ${text}`)
err.status = 403
throw err
}
if (res.status >= 500 && attempt < MAX_RETRIES) {
console.warn(`${method} ${path} -> ${res.status}, retrying in ${5 * attempt}s`)
await sleep(5000 * attempt)
continue
}
if (!res.ok) {
const text = await res.text()
const err = new Error(`${method} ${path} -> ${res.status}: ${text}`)
err.status = res.status
throw err
}
if (res.status === 204) return null
return res.json()
}
throw new Error(`${method} ${path}: exhausted retries`)
}
/** Paginated search/issues. Caps at `maxPages` * 100 results. */
export async function searchIssues(query, { maxPages = 5 } = {}) {
const items = []
for (let page = 1; page <= maxPages; page++) {
const data = await api(`/search/issues?q=${encodeURIComponent(query)}&per_page=100&page=${page}`)
items.push(...(data.items ?? []))
if ((data.items ?? []).length < 100) break
}
return items
}
export async function listPrFiles(fullRepo, number, { maxPages = 3 } = {}) {
const files = []
for (let page = 1; page <= maxPages; page++) {
const batch = await api(`/repos/${fullRepo}/pulls/${number}/files?per_page=100&page=${page}`)
files.push(...batch)
if (batch.length < 100) break
}
return files
}
export function appendOutput(name, value) {
const out = process.env.GITHUB_OUTPUT
if (out) fs.appendFileSync(out, `${name}=${value}\n`)
console.log(`output ${name}=${value}`)
}
export function appendSummary(markdown) {
const summary = process.env.GITHUB_STEP_SUMMARY
if (summary) fs.appendFileSync(summary, markdown + "\n")
}
/**
* Absolute deadline timestamp (ms since epoch) for a wall-clock budget.
* Used by triage/edit to stop before the job timeout rather than silently
* truncating.
*/
export function deadline(minutes) {
return Date.now() + Number(minutes) * 60 * 1000
}
/** Remaining milliseconds until a deadline; never negative. */
export function remainingMs(deadlineMs) {
return Math.max(0, Number(deadlineMs) - Date.now())
}
/**
* Backoff schedule between kilo-run attempts. Production waits 60s then 300s
* (observed outage lasted ~11 min; batch 8 recovered on attempt 2). When
* DOCS_SYNC_BACKOFF_MS is set it replaces EVERY wait (`0` disables waiting);
* the workflow never sets it — only selftests do.
*/
export function backoffMsForAttempt(attempt) {
// attempt is 1-based; wait happens after attempt N before attempt N+1.
const override = process.env.DOCS_SYNC_BACKOFF_MS
if (override !== undefined && override !== "") {
const n = Number(override)
return Number.isFinite(n) && n >= 0 ? n : 0
}
// After attempt 1 → 60s; after attempt 2 → 300s; nothing after the last.
if (attempt === 1) return 60_000
if (attempt === 2) return 300_000
return 0
}
/**
* Blocking sleep used between kilo-run retries. Prefer this over async sleep
* so edit/triage stay synchronous around spawnSync.
*/
export function sleepSync(ms) {
const n = Number(ms)
if (!Number.isFinite(n) || n <= 0) return
const end = Date.now() + n
// Atomics.wait is the portable Node sync sleep (no busy loop).
const sab = new SharedArrayBuffer(4)
const view = new Int32Array(sab)
while (Date.now() < end) {
const left = end - Date.now()
if (left <= 0) break
Atomics.wait(view, 0, 0, Math.min(left, 2_147_483_647))
}
}
const STDERR_TAIL_LINES = 20
const STDERR_TAIL_CHARS = 4_000
// CSI sequences (colour, cursor moves, erases). kilo renders its TUI to stderr,
// so an unstripped tail lands in the rolling PR's pending table as
// "^[[0m→ ^[[0mRead packages/..." and the cause is unreadable. Stripped before
// the line/char slice so escapes do not eat the budget. The persisted
// docs-sync-out/kilo-stderr-*.log stays raw — that is the debugging record.
// eslint-disable-next-line no-control-regex
const ANSI_CSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g
function tailText(text, { lines = STDERR_TAIL_LINES, chars = STDERR_TAIL_CHARS } = {}) {
const s = String(text ?? "")
.replace(ANSI_CSI, "")
.trim()
if (!s) return ""
const lastLines = s.split("\n").slice(-lines).join("\n")
return lastLines.length > chars ? lastLines.slice(-chars) : lastLines
}
/**
* Artifact files are raw: GitHub masks secret values in log streams only, and the runner
* env holds long-lived secrets (KILO_API_KEY), so exact values of secret-looking env vars
* are redacted before stdout/stderr is persisted or printed.
* Matching is exact-substring and case-sensitive on values — JSON-escaped, base64'd, or
* line-wrapped renderings and values shorter than 8 chars survive (same limitation as
* GitHub's own log masking); this is defense-in-depth, not a guarantee the logs are clean.
*/
export function redactEnvSecrets(text) {
let out = String(text ?? "")
// Also match CREDENTIAL/PASSWORD/ORG_ID/_PAT (e.g. KILO_ORG_ID, GH_PAT) beyond KEY|TOKEN|SECRET.
const nameRe = /KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|ORG_ID|_PAT$/i
const candidates = []
for (const [name, value] of Object.entries(process.env)) {
if (!nameRe.test(name)) continue
if (typeof value !== "string" || value.length < 8) continue
candidates.push(value)
}
// Longer values first so a shorter secret that is a prefix of a longer one cannot leave a remainder.
candidates.sort((a, b) => b.length - a.length)
for (const value of candidates) {
if (!out.includes(value)) continue
out = out.split(value).join("***")
}
return out
}
/** Max bytes of child stderr persisted to docs-sync-out/ (full buffer, not the console tail). */
const STDERR_LOG_MAX_CHARS = 8 * 1024 * 1024
/**
* Run `kilo` via spawnSync so stderr is always recoverable — including when
* the child exits 0 after writing a diagnostic (execFileSync cannot return
* piped stderr on exit 0; that path lost every diagnostic on run 30122603016).
*
* streamStdout:true → inherit fd 1 (edit live log); false → capture stdout
* (triage parses it). stderr is always buffered.
*
* Always writes the full captured stderr to
* docs-sync-out/kilo-stderr-<sanitized-label>.log (unconditional — success and
* failure). The console return value still uses the short tailText.
*/
export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo" }) {
const result = spawnSync("kilo", args, {
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
timeout: timeoutMs,
stdio: ["ignore", streamStdout ? "inherit" : "pipe", "pipe"],
})
const timedOut = Boolean(result.error && result.error.code === "ETIMEDOUT")
const exitCode =
typeof result.status === "number" ? result.status : timedOut ? null : result.status === null ? null : result.status
const stderrRaw = String(result.stderr ?? "")
const stderrSafe = redactEnvSecrets(stderrRaw)
const stderrTail = tailText(stderrSafe)
const stdoutSafe = streamStdout ? "" : redactEnvSecrets(String(result.stdout ?? ""))
// ok is "process finished without OS-level failure". Callers still treat a
// missing summary / unparseable output as failure even when ok is true —
// exit 0 is not success for the docs-sync bot.
const ok = !result.error && result.status === 0
// Persist full stderr on every call (not gated on ok/exitCode/summary). Cap is
// generous (megabytes) so long batch dumps keep auto-rejecting lines; console
// still uses the short tail above.
try {
fs.mkdirSync("docs-sync-out", { recursive: true })
const safe = label.replace(/[^A-Za-z0-9._-]/g, "-")
const body = stderrSafe.length > STDERR_LOG_MAX_CHARS ? stderrSafe.slice(-STDERR_LOG_MAX_CHARS) : stderrSafe
fs.writeFileSync(`docs-sync-out/kilo-stderr-${safe}.log`, body)
} catch (err) {
console.warn(`${label}: failed to write kilo-stderr log: ${err.message}`)
}
if (result.error && !timedOut) {
console.warn(`${label}: spawn error: ${result.error.message}`)
}
return { ok, stdout: stdoutSafe, stderrTail, exitCode, timedOut }
}
+105
View File
@@ -0,0 +1,105 @@
// 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)
*
* Outputs: branch, mode (update|fresh|conflict), pr_number (empty when fresh).
*/
import { execFileSync } from "node:child_process"
import { pathToFileURL } from "node:url"
import { api, appendOutput, repo, searchIssues } from "./lib.mjs"
export const DEFAULT_BRANCH = "docs/auto-sync"
const defaultGit = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim()
/**
* Merge origin/main into the current branch. On a genuine conflict, abort the
* merge, switch to a dated fallback branch from origin/main, and return
* mode=conflict so human commits on the rolling branch stay untouched. Any
* other merge failure (missing identity, corrupt ref, fetch issues) is
* rethrown so the job fails loudly.
*/
export function mergeOrFallback({ branch, git = defaultGit }) {
try {
git(["merge", "origin/main", "--no-edit"])
return { branch, mode: "update" }
} catch (err) {
// Conflict ⇔ unmerged index entries (or MERGE_HEAD still present).
// Identity failures and similar abort before a merge is started, so
// merge --abort would itself fail — those must rethrow.
let unmerged = ""
try {
unmerged = git(["ls-files", "--unmerged"])
} catch {
// ls-files itself failing is not a conflict signal
}
let mergeInProgress = false
try {
git(["rev-parse", "-q", "--verify", "MERGE_HEAD"])
mergeInProgress = true
} catch {
mergeInProgress = false
}
const isConflict = unmerged.length > 0 || mergeInProgress
if (!isConflict) throw err
console.warn(`merge of origin/main into ${branch} conflicted.`)
console.warn(
"Leaving the conflicted branch untouched so human commits are preserved; continuing on a fresh dated branch.",
)
git(["merge", "--abort"])
const fallback = `${DEFAULT_BRANCH}-${new Date().toISOString().slice(0, 10)}`
try {
git(["fetch", "origin", `+refs/heads/${fallback}:refs/remotes/origin/${fallback}`])
} catch {
console.log(`dated branch ${fallback} does not exist on origin yet; will create it on push`)
}
git(["checkout", "-B", fallback, "origin/main"])
return { branch: fallback, mode: "conflict" }
}
}
async function main() {
const git = defaultGit
const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 })
let mode = "fresh"
let prNumber = ""
let branch = DEFAULT_BRANCH
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.
try {
git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`])
} catch {
console.log(`branch ${branch} does not exist on origin yet; will create it on push`)
}
git(["checkout", "-B", branch, "origin/main"])
}
appendOutput("branch", branch)
appendOutput("mode", mode)
appendOutput("pr_number", prNumber)
console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`)
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (isMain) {
main().catch((err) => {
console.error(err)
process.exit(1)
})
}
+25
View File
@@ -0,0 +1,25 @@
// kilocode_change - new file
/**
* Line-wise stdin→stdout filter that redacts secret-looking env values.
* Used in the docs-sync workflow so kilo stdout piped to edit-log.txt is safe.
* Env values contain no newlines, so line-wise processing never splits a value.
*/
import { redactEnvSecrets } from "./lib.mjs"
let carry = ""
process.stdin.setEncoding("utf8")
process.stdin.on("data", (chunk) => {
carry += chunk
let idx
while ((idx = carry.indexOf("\n")) !== -1) {
const line = carry.slice(0, idx + 1)
carry = carry.slice(idx + 1)
process.stdout.write(redactEnvSecrets(line))
}
})
process.stdin.on("end", () => {
if (carry.length > 0) process.stdout.write(redactEnvSecrets(carry))
})
+153
View File
@@ -0,0 +1,153 @@
// kilocode_change - new file
/**
* Pure helpers for intercepting revert PRs during docs-sync collect.
* No I/O, no imports — offline-testable title/body/annotation logic only.
*/
/** Detect revert PR titles. Returns "conventional" | "github-native" | null. */
export function revertTitleKind(title) {
const t = String(title ?? "")
if (/^revert(\(.+\))?!?:/i.test(t)) return "conventional"
if (/^revert\s+["']/i.test(t)) return "github-native"
return null
}
/**
* Parse revert targets from a PR body. `defaultRepo` ("Kilo-Org/kilocode") resolves bare `#N`.
* Returns [{ repo, number, url }] with url = `https://github.com/${repo}/pull/${number}`.
* Handles the conventional trailer form: a line starting with `Reverts` (case-insensitive),
* e.g. `Reverts #12249 and #12481.`, `Reverts Kilo-Org/cloud#42.`, comma-separated lists,
* several such lines in one body, and bulleted/quoted single-line trailers
* (`- Reverts #12249`, `> Reverts #12249`).
* NOT handled (documented limitation): `This reverts commit <sha>.` (no PR number),
* mid-sentence forms (`This reverts #5.`), narrative mentions (`Revert the fix in #99999`
* is prose, not a trailer — must NOT produce a target), `Revertsomething #5` (prose glued
* to the word — `\b` word boundary makes it inert), and multi-line lists
* (`Reverts:\n- #1\n- #2`).
*/
export function parseRevertTargets(body, defaultRepo) {
const text = String(body ?? "")
const repoDefault = String(defaultRepo ?? "")
const seen = new Set()
const out = []
const lineRe = /^[ \t>*-]*reverts\b[ \t:]*([^\n]*)/gim
let lineMatch
while ((lineMatch = lineRe.exec(text)) !== null) {
const capture = lineMatch[1] ?? ""
const refRe = /(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+))?#(\d+)/g
let refMatch
while ((refMatch = refRe.exec(capture)) !== null) {
const repo = refMatch[1] || repoDefault
const number = Number(refMatch[2])
if (!repo || !Number.isInteger(number)) continue
const url = `https://github.com/${repo}/pull/${number}`
if (seen.has(url)) continue
seen.add(url)
out.push({ repo, number, url })
}
}
return out
}
/**
* revertSignals: [{ url, merged_at, targets: [{ repo, number, url }] }]
* Returns Map<targetUrl, { url, merged_at }> — the annotation for each reverted PR.
* Map keys are lowercased target urls; values keep signal.url as authored.
* Revert-of-revert: a signal whose own url is itself a target of another signal is
* dropped entirely (a re-land: net effect zero — one set lookup, no chain walking).
*/
export function computeRevertAnnotations(revertSignals) {
const signals = Array.isArray(revertSignals) ? revertSignals : []
const revertedUrls = new Set()
for (const signal of signals) {
for (const t of signal?.targets ?? []) {
if (t?.url) revertedUrls.add(t.url.toLowerCase())
}
}
const annotations = new Map()
for (const signal of signals) {
if (!signal?.url || revertedUrls.has(signal.url.toLowerCase())) continue
for (const t of signal.targets ?? []) {
if (!t?.url) continue
annotations.set(t.url.toLowerCase(), { url: signal.url, merged_at: signal.merged_at })
}
}
return annotations
}
/**
* Applies computeRevertAnnotations to digest entries in place: an entry whose url
* was reverted gains `reverted_by: { url, merged_at }` (the reverter).
* Lookup is case-insensitive; applied pairs still report entry.url (canonical).
* Returns applied [targetUrl, reverterUrl] pairs (digest entries only) for reporting.
*/
export function applyRevertAnnotations(digest, revertSignals) {
const annotations = computeRevertAnnotations(revertSignals)
const applied = []
for (const entry of digest ?? []) {
if (!entry?.url) continue
const ann = annotations.get(entry.url.toLowerCase())
if (!ann) continue
entry.reverted_by = { url: ann.url, merged_at: ann.merged_at }
applied.push([entry.url, ann.url])
}
return applied
}
/**
* Reports intercepted reverts whose targets received no digest annotation, for the
* step summary. `signals` is the same shape as computeRevertAnnotations takes;
* `appliedPairs` is applyRevertAnnotations' return ([targetUrl, reverterUrl]).
* Returns { missed, unparsed, chains }:
* - missed: [{ url, targets: [targetUrl, ...] }] — one entry per signal with at least
* one unannotated plain target, listing exactly the targets that missed (a partially-covered
* revert still surfaces its uncovered targets). Plain = not itself an intercepted revert.
* - unparsed: [url, ...] — urls of signals with zero targets (they already warn at
* intercept time).
* - chains: [{ url, targets: [targetUrl, ...] }] — signals involved in a revert chain,
* reported for human visibility only; the pipeline never resolves a chain's net effect.
* A signal lands here when EITHER its own url is a target of another signal (cancelled
* by computeRevertAnnotations — lists ALL its targets), OR at least one of its targets
* is itself an intercepted revert (re-land chain link — lists ONLY those chain-link
* targets; plain targets still flow through missed).
* Cancelled signals skip missed/unparsed. Chain-linked (non-cancelled) signals may also
* appear in missed for their unannotated plain targets. Chain-link targets never appear
* in missed. All url comparisons are case-insensitive.
*/
export function unannotatedRevertSignals(signals, appliedPairs) {
const list = Array.isArray(signals) ? signals : []
const annotated = new Set((appliedPairs ?? []).map(([target]) => String(target ?? "").toLowerCase()))
const signalUrls = new Set(list.map((s) => String(s?.url ?? "").toLowerCase()))
const cancelledUrls = new Set()
for (const s of list) {
for (const t of s?.targets ?? []) {
if (t?.url) cancelledUrls.add(t.url.toLowerCase())
}
}
const missed = []
const unparsed = []
const chains = []
for (const s of list) {
const url = s?.url
if (!url) continue
const targets = (s.targets ?? []).map((t) => t?.url).filter(Boolean)
const cancelled = cancelledUrls.has(url.toLowerCase())
const chainTargets = targets.filter((u) => signalUrls.has(u.toLowerCase()))
if (cancelled) {
chains.push({ url, targets })
continue
}
if (chainTargets.length > 0) {
chains.push({ url, targets: chainTargets })
}
if (targets.length === 0) {
unparsed.push(url)
continue
}
const plainTargets = targets.filter((u) => !signalUrls.has(u.toLowerCase()))
const missing = plainTargets.filter((u) => !annotated.has(u.toLowerCase()))
if (missing.length > 0) missed.push({ url, targets: missing })
}
return { missed, unparsed, chains }
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
You are the triage pass of an automated documentation pipeline for Kilo Code. Kilo Code is an open-source agentic engineering platform: VS Code extension, JetBrains plugin, CLI, and the kilo.ai cloud platform (teams, KiloClaw, gateway, code reviews).
The attached `digest.json` file contains PRs recently merged to Kilo-Org/cloud and Kilo-Org/kilocode. Your only job is to decide which of them require changes to the public product documentation at kilo.ai/docs.
A PR is docs-worthy ONLY if a user of Kilo Code would need to learn something new or change how they use the product after this PR ships. Examples: new commands, flags, settings, UI workflows, providers, pricing/limits changes, breaking behavior changes, or fixes that change documented behavior.
A PR is NOT docs-worthy when it is: an internal refactor, infrastructure or CI work, a feature-flag scaffold that is not yet user-visible, test or dependency work, a bug fix that merely restores already-documented behavior, a change only visible to contributors or self-hosters, an internal technical fix or plumbing change with no user-visible behavior or workflow change (for example stream-liveness/watchdog plumbing, internal retry or timeout handling with no user-facing setting), or UI or rendering polish that does not change what a user must do or learn.
Rules:
- Include every input PR exactly once, identified by its `number` and `url`. Never invent PRs.
- A digest entry with a `reverted_by` field had its changes reverted by that later PR in the same window. Treat such PRs as not docs-worthy unless the entry's body or file list shows clear evidence the change was re-landed after the revert. When `reverted_by` drives your verdict, the `reason` must cite it, e.g. "reverted by https://github.com/Kilo-Org/kilocode/pull/12497".
- When unsure, set `docs_worthy` to false and explain the doubt in `reason`.
- `target_sections` is only filled for docs-worthy PRs. Use rough docs areas, e.g. `getting-started`, `code-with-ai/platforms/cli`, `code-with-ai/platforms/vscode`, `code-with-ai/agents`, `ai-providers`, `teams`, `enterprise`, `automate`.
- `reason` is one short sentence, written for the human who reviews the final docs PR.
- `priority` reflects user impact: high = most users affected, medium = notable subset, low = edge case.
Respond with a STRICT JSON array and nothing else: no prose, no markdown fences, no comments. Schema:
[{"pr": 123, "url": "https://github.com/Kilo-Org/kilocode/pull/123", "docs_worthy": true, "reason": "Adds --variant flag to kilo run", "target_sections": ["code-with-ai/platforms/cli"], "priority": "high"}]
+194
View File
@@ -0,0 +1,194 @@
// kilocode_change - new file
/**
* Runs the LLM triage pass over docs-sync-out/digest.json in chunks.
*
* A daily window holds ~30-50 PRs; a replay can hold several hundred. A
* single triage call over that volume truncates its JSON output, so the
* digest is split into chunks of CHUNK_SIZE and each chunk is triaged with
* its own `kilo run` call. A chunk that fails, is only partially classified,
* or is deferred by the wall-clock budget is marked pending:true (still
* 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
* 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.
*/
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"
const CHUNK_SIZE = 25
const ATTEMPTS = 3
const OUT_DIR = "docs-sync-out"
const CHUNK_TIMEOUT_MS = 10 * 60 * 1000
const HERE = path.dirname(fileURLToPath(import.meta.url))
const prompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8")
const model = process.env.TRIAGE_MODEL
if (!model) throw new Error("TRIAGE_MODEL is required")
const TRIAGE_BUDGET_MINUTES = Number(process.env.TRIAGE_BUDGET_MINUTES) || 35
const digest = JSON.parse(fs.readFileSync(`${OUT_DIR}/digest.json`, "utf8"))
function formatCause(result) {
const bits = []
if (result.timedOut) bits.push("timed out")
if (result.exitCode !== null && result.exitCode !== undefined) bits.push(`exit ${result.exitCode}`)
if (result.stderrTail) bits.push(result.stderrTail.replaceAll("\n", " ").slice(0, 200))
return bits.join("; ") || "no diagnostic"
}
function pendingEntry(d, reason) {
return {
pr: d.number,
url: d.url,
docs_worthy: false,
pending: true,
reason,
target_sections: [],
priority: "medium",
}
}
function triageChunk(chunk, index, budgetDeadline) {
const chunkFile = `${OUT_DIR}/triage-chunk-${index}.json`
fs.writeFileSync(chunkFile, JSON.stringify(chunk, null, 2))
let lastCause = "triage failed to classify this PR"
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
const left = remainingMs(budgetDeadline)
if (left < CHUNK_TIMEOUT_MS) {
lastCause = `triage budget exhausted before chunk ${index} attempt ${attempt}`
console.warn(
`chunk ${index}: stopping retries — remaining budget cannot fit another ${CHUNK_TIMEOUT_MS / 60000}m attempt`,
)
break
}
// Headless `kilo run` auto-rejects every permission ask; without --auto the
// agent cannot run shell commands. SECURITY: --auto grants unrestricted bash
// to an agent steered by external PR content. Hardening deferred: a scoped
// 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],
timeoutMs: Math.min(CHUNK_TIMEOUT_MS, left),
streamStdout: false,
label: `triage chunk ${index} attempt ${attempt}`,
})
const raw = result.stdout
if (raw) fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw)
const entries = raw ? parseTriageEntries(raw) : null
if (entries) {
// An entry for a PR outside this chunk must not win the shared dedupe
// against the chunk that actually owns it — drop foreign entries.
const allowed = new Set(chunk.map((d) => d.url))
const owned = entries.filter((e) => allowed.has(e.url))
if (owned.length !== entries.length) {
console.warn(`chunk ${index}: dropped ${entries.length - owned.length} entries for PRs outside the chunk`)
}
if (owned.length > 0) return owned
}
// Exit 0 is not success: unparseable output is a failure logged WITH
// the captured stderrTail and exit code on every attempt.
const cause = formatCause(result)
lastCause = `triage chunk ${index}: ${cause}`
console.warn(
`chunk ${index} attempt ${attempt}: no valid JSON in output` +
` (exit ${result.exitCode}${result.timedOut ? ", timed out" : ""})` +
(result.stderrTail ? `\nstderr tail:\n${result.stderrTail}` : "\nstderr tail: (empty)"),
)
if (attempt < ATTEMPTS) {
const wait = backoffMsForAttempt(attempt)
const afterWait = remainingMs(budgetDeadline) - wait
if (wait > 0 && afterWait >= CHUNK_TIMEOUT_MS) {
console.warn(`chunk ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`)
sleepSync(wait)
} else if (wait > 0) {
console.warn(
`chunk ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`,
)
}
}
}
console.warn(
`::warning::chunk ${index} failed triage after up to ${ATTEMPTS} attempts; marking ${chunk.length} PRs pending`,
)
return chunk.map((d) => pendingEntry(d, lastCause.includes("triage failed") ? lastCause : `triage failed to classify this PR (${lastCause})`))
}
const chunks = []
for (let i = 0; i < digest.length; i += CHUNK_SIZE) {
chunks.push(digest.slice(i, i + CHUNK_SIZE))
}
console.log(`triaging ${digest.length} PRs in ${chunks.length} chunks of up to ${CHUNK_SIZE}`)
const budgetDeadline = deadline(TRIAGE_BUDGET_MINUTES)
const merged = []
const seen = new Set()
for (let i = 0; i < chunks.length; i++) {
const left = remainingMs(budgetDeadline)
if (left < CHUNK_TIMEOUT_MS) {
const deferredPrs = chunks.slice(i).reduce((n, c) => n + c.length, 0)
console.warn(
`stopping triage before chunk ${i}: remaining budget (${Math.ceil(left / 1000)}s) cannot fit a ${CHUNK_TIMEOUT_MS / 60000}m chunk; deferring ${deferredPrs} PRs`,
)
const cause = `triage budget exhausted before chunk ${i} (${Math.ceil(left / 1000)}s left)`
for (let j = i; j < chunks.length; j++) {
for (const d of chunks[j]) {
if (seen.has(d.url)) continue
seen.add(d.url)
merged.push(pendingEntry(d, cause))
}
}
break
}
for (const e of triageChunk(chunks[i], i, budgetDeadline)) {
if (seen.has(e.url)) continue
seen.add(e.url)
merged.push(e)
}
}
// Coverage: every digest PR gets a triage entry. Partial-chunk backfill and
// any other missing URL are pending:true — not a genuine "not worthy" verdict.
for (const d of digest) {
if (seen.has(d.url)) continue
merged.push(pendingEntry(d, "not classified by triage"))
}
fs.writeFileSync(`${OUT_DIR}/triage.json`, JSON.stringify(merged, null, 2))
const worthy = merged.filter((e) => e.docs_worthy).length
const pending = merged.filter((e) => e.pending === true)
console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy, ${pending.length} pending`)
// Upsert is gated off when worthy == 0, so triage emits its own Step Summary
// listing every PR it marked pending:true and why.
if (pending.length > 0) {
const lines = pending.map((e) => `- [${e.url}] ${e.reason}`)
appendSummary(
`### docs-sync: triage pending (will retry)\n\n${pending.length} PR(s) were not classified and will be re-collected on the next run:\n\n${lines.join("\n")}`,
)
}
// Replay warning (S2j): warn IFF since-override AND something pending AND
// docs-worthy count is 0 (Upsert is gated off, so noDiffReport never runs).
const sinceOverride = process.env.SINCE_OVERRIDE === "true"
if (sinceOverride && pending.length > 0 && worthy === 0) {
console.warn(
"::warning::docs-sync since-override replay left uncovered PRs and wrote no PR body (worthy=0); re-run the override — the watermark was not held back in the body",
)
}
+471
View File
@@ -0,0 +1,471 @@
// 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.
*
* 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.
*
* Watermark invariant: processed-through never moves past a PR that has no
* terminal outcome. Terminal := action !== "pending" (a deliberate agent
* "skipped" IS terminal). Uncovered PRs hold the marker at earliest
* merged_at 1 ms so collect's merged:>=since re-collects them next run.
* Three review rounds found four independent defects in a queue-based
* alternative (unreachable gate, empty-PR creation, cap-overflow loss,
* draft-state corruption); a held-back watermark has none of those modes.
*/
import { execFileSync } from "node:child_process"
import fs from "node:fs"
import { pathToFileURL } from "node:url"
const BRANCH = process.env.BRANCH || "docs/auto-sync"
const FILE_CAP = 15
const ROW_CAP = 150
const PENDING_DISPLAY_CAP = 60
const SUMMARY_FILE = ".docs-sync-summary.json"
const DOCS_PATH = "packages/kilo-docs"
const git = (args) => execFileSync("git", args, { 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
// section boundaries or the processed-through watermark.
function clean(value) {
return String(value ?? "").replaceAll("<!--", "").replaceAll("-->", "")
}
function shortRef(url) {
return clean(url).replace("https://github.com/", "").replace("/pull/", "#")
}
function changeRow(e) {
return `| ${clean(e.action).replaceAll("|", "\\|")} | [${shortRef(e.url)}](${clean(e.url)}) |`
}
function skippedRow(e) {
const reason = clean(e.reason).replaceAll("|", "\\|").replaceAll("\n", " ")
return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |`
}
function pendingRow(e) {
const reason = clean(e.reason ?? e.cause ?? "").replaceAll("|", "\\|").replaceAll("\n", " ")
return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |`
}
export function extractSectionRows(body, name) {
const m = String(body ?? "").match(
new RegExp(`<!--\\s*docs-sync:${name}:start\\s*-->([\\s\\S]*?)<!--\\s*docs-sync:${name}:end\\s*-->`),
)
if (!m) return []
return m[1]
.split("\n")
.map((l) => l.trim())
.filter(
(l) =>
l.startsWith("|") &&
!l.startsWith("| ---") &&
!/^\|\s*Docs change/.test(l) &&
!/^\|\s*PR\s*\|/.test(l) &&
!/^\|\s*Why\s*\|/.test(l),
)
}
function section(name, header, rows) {
const body = rows.length > 0 ? [header, "| --- | --- |", ...rows].join("\n") : "_None._"
return `<!-- docs-sync:${name}:start -->\n${body}\n<!-- docs-sync:${name}:end -->`
}
export function renderBody({ date, since, through, changesRows, pendingRows, skippedRows, verified, draftReasons, note }) {
const pendingDisplay =
pendingRows.length > PENDING_DISPLAY_CAP
? [...pendingRows.slice(0, PENDING_DISPLAY_CAP), `| +${pendingRows.length - PENDING_DISPLAY_CAP} more | |`]
: pendingRows
return `## Automated docs sync — ${date}
This PR keeps kilo.ai/docs in sync with features merged to [Kilo-Org/cloud](https://github.com/Kilo-Org/cloud) and [Kilo-Org/kilocode](https://github.com/Kilo-Org/kilocode). Every change below links to the merged PR it documents.
- 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
${section("changes", "| Docs change | Source |", changesRows)}
### Pending — will retry
${section("pending", "| PR | Why |", pendingDisplay)}
### Considered, no docs change needed
${section("skipped", "| PR | Reason |", skippedRows)}
---
(bot) Generated by the docs-sync workflow. Humans review and merge; while this PR stays open, the next daily run appends new changes here. Branch: \`${BRANCH}\`.
<!-- docs-sync: processed-through ${through} -->
`
}
function mergeRows(oldRows, newRows) {
const seen = new Set()
const out = []
for (const row of [...oldRows, ...newRows]) {
if (seen.has(row)) continue
seen.add(row)
out.push(row)
}
return out.slice(-ROW_CAP)
}
function readJson(path, fallback) {
try {
return JSON.parse(fs.readFileSync(path, "utf8"))
} catch {
return fallback
}
}
/**
* Uncovered = (worthy URLs with no summary row) (summary action "pending")
* (triage entries with pending: true). A worthy PR is covered iff it has a
* summary row whose action !== "pending" and carries no triage pending flag.
*/
export function computeUncovered({ worthy, summary, triage }) {
const worthyList = Array.isArray(worthy) ? worthy : []
const summaryList = Array.isArray(summary) ? summary : []
const triageList = Array.isArray(triage) ? triage : []
const summaryByUrl = new Map()
for (const e of summaryList) {
if (e?.url) summaryByUrl.set(e.url, e)
}
const triagePendingByUrl = new Map()
for (const e of triageList) {
if (e?.url && e.pending === true) triagePendingByUrl.set(e.url, e)
}
/** @type {Map<string, { url: string, pr?: number, reason: string }>} */
const out = new Map()
for (const w of worthyList) {
const url = w?.url
if (!url) continue
const row = summaryByUrl.get(url)
if (!row) {
out.set(url, {
url,
pr: w.number ?? w.pr,
reason: "no edit summary row (edit pass did not cover this PR)",
})
continue
}
if (row.action === "pending") {
out.set(url, {
url,
pr: row.pr ?? w.number ?? w.pr,
reason: row.reason || "edit pass pending",
})
}
}
// Summary pending rows for URLs not in worthy (defensive).
for (const row of summaryList) {
if (row?.action === "pending" && row.url && !out.has(row.url)) {
out.set(row.url, {
url: row.url,
pr: row.pr,
reason: row.reason || "edit pass pending",
})
}
}
for (const [url, e] of triagePendingByUrl) {
if (out.has(url)) continue
out.set(url, {
url,
pr: e.pr,
reason: e.reason || "triage pending",
})
}
return [...out.values()]
}
/**
* processed-through = now when uncovered is empty; otherwise earliest
* merged_at among uncovered PRs minus 1 ms (from digest-full.json).
* When uncovered is non-empty but no merged_at resolves, hold at
* `fallback` (the run's window start / SINCE): every uncovered PR was
* collected via merged:>=since, so holding there re-collects all of them.
* Never advance past unresolved uncovered PRs (Defect-B permanent-loss).
*/
export function computeProcessedThrough({ uncovered, digest, now, fallback }) {
const nowIso = typeof now === "string" ? now : new Date(now).toISOString()
if (!uncovered || uncovered.length === 0) return nowIso
const digestList = Array.isArray(digest) ? digest : []
const byUrl = new Map(digestList.filter((d) => d?.url).map((d) => [d.url, d]))
let earliest = null
for (const u of uncovered) {
const d = byUrl.get(u.url)
const mergedAt = d?.merged_at
if (!mergedAt) continue
const t = Date.parse(mergedAt)
if (!Number.isFinite(t)) continue
if (earliest === null || t < earliest) earliest = t
}
if (earliest === null) {
// digest-full missing/corrupt while uncovered is non-empty: hold at
// window start so collect's merged:>=since re-collects every PR.
// Never use now1ms — that strands uncovered PRs permanently.
const fallbackMs = fallback == null ? NaN : Date.parse(fallback)
if (!Number.isFinite(fallbackMs)) {
throw new Error(
`docs-sync: cannot resolve merged_at for ${uncovered.length} uncovered PR(s) and fallback/SINCE is missing or unparseable; refusing to advance processed-through`,
)
}
const fallbackIso = new Date(fallbackMs).toISOString()
console.warn(
`::warning::docs-sync: merge times for ${uncovered.length} uncovered PR(s) could not be resolved; holding watermark at window start ${fallbackIso}`,
)
return fallbackIso
}
return new Date(earliest - 1).toISOString()
}
/**
* Route summary + triage into the three body sections.
* changesRows = action neither skipped nor pending
* pendingRows = uncovered from computeUncovered
* skippedRows = action === "skipped" triage docs_worthy false && !pending
*/
export function routeRows({ summary, triage, uncovered }) {
const summaryList = Array.isArray(summary) ? summary : []
const triageList = Array.isArray(triage) ? triage : []
const uncoveredList = Array.isArray(uncovered) ? uncovered : []
const changesEntries = summaryList.filter((e) => e.action !== "skipped" && e.action !== "pending")
const skippedEntries = [
...triageList.filter((e) => e.docs_worthy === false && e.pending !== true),
...summaryList.filter((e) => e.action === "skipped"),
]
return {
changesRows: changesEntries.map(changeRow),
pendingRows: uncoveredList.map(pendingRow),
skippedRows: skippedEntries.map(skippedRow),
}
}
/**
* Drop pre-existing Considered rows whose reason contains any of the three
* legacy failure literals (substring match — live rows carry longer strings).
* Genuine no-doc-needed rows are untouched.
*/
export function dropLegacySkipped(rows) {
const list = Array.isArray(rows) ? rows : []
const needles = ["edit pass failed or timed out", "triage failed to classify", "not classified by triage"]
return list.filter((row) => {
const s = String(row ?? "")
return !needles.some((n) => s.includes(n))
})
}
/**
* No-diff early-return report. Returns summary markdown and an optional
* replay warning. Warns IFF sinceOverride && uncovered non-empty (no commit
* happened — that is the caller's situation).
*/
export function noDiffReport({ uncovered, sinceOverride }) {
const list = Array.isArray(uncovered) ? uncovered : []
const lines =
list.length === 0
? ["The agent found nothing worth documenting in this window."]
: [
`No packages/kilo-docs diff was produced, but ${list.length} PR(s) remain uncovered and will be re-collected on the next scheduled run:`,
"",
...list.map((u) => `- [${u.url}] ${u.reason || "uncovered"}`),
]
const summary = `### docs-sync: no docs changes\n\n${lines.join("\n")}`
let warning = null
if (sinceOverride && list.length > 0) {
warning =
"docs-sync since-override replay left uncovered PRs and wrote no PR body (no docs commit); re-run the override — the watermark was not held back in the body"
}
return { summary, warning }
}
async function main() {
const { api, appendOutput, appendSummary, repo } = 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 agent's run summary is consumed here and never committed.
const agentSummary = readJson(SUMMARY_FILE, [])
fs.rmSync(SUMMARY_FILE, { force: true })
const triage = readJson("docs-sync-out/triage.json", [])
const worthy = readJson("docs-sync-out/worthy.json", [])
const digest = readJson("docs-sync-out/digest-full.json", [])
// Order matters: compute uncovered BEFORE the no-diff early return so
// noDiffReport can name every held-back PR.
const uncovered = computeUncovered({ worthy, summary: agentSummary, triage })
if (git(["status", "--porcelain", "--", DOCS_PATH]) === "") {
console.log("no packages/kilo-docs changes produced; nothing to commit")
const { summary, warning } = noDiffReport({ uncovered, sinceOverride })
appendSummary(summary)
if (warning) console.warn(`::warning::${warning}`)
return
}
// Git identity is configured once in docs-sync.yml (Configure git identity)
// before any commit-creating step, including prepare-branch's merge.
git(["add", DOCS_PATH])
git(["commit", "-m", `docs: sync with merged PRs (${date})`])
// Watermark: now when fully covered; else earliest uncovered merged_at 1ms.
// Pass SINCE as fallback so missing digest-full cannot strand uncovered PRs.
const through = computeProcessedThrough({ uncovered, digest, now, fallback: since })
// The draft cap bounds the cumulative PR diff, not just this run's commit.
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 = changedFiles.filter(
(f) => !f.startsWith("packages/kilo-docs/pages/") && !f.startsWith("packages/kilo-docs/lib/nav/"),
)
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
git(mode === "update" ? ["push", "origin", `HEAD:${BRANCH}`] : ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`])
const { changesRows: changesNew, pendingRows: pendingNew, skippedRows: skippedNew } = routeRows({
summary: agentSummary,
triage,
uncovered,
})
let oldChanges = []
let oldSkipped = []
let oldPending = []
if (mode === "update" && existingPr) {
const pr = await api(`/repos/${repo()}/pulls/${existingPr}`)
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 body = renderBody({
date,
since,
through,
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",
body: { name: "auto-docs", color: "1d76db", description: "Automated docs-sync PRs" },
})
} catch (err) {
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"] } })
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.`,
},
})
}
}
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`,
)
console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length}, uncovered=${uncovered.length}, through=${through})`)
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (isMain) {
main().catch((err) => {
console.error(err)
process.exit(1)
})
}
+107
View File
@@ -0,0 +1,107 @@
// kilocode_change - new file
/**
* Resolves the docs-sync watermark: the timestamp of the newest source PR the
* bot has already processed. Derived from the bot's own PRs (marker in the PR
* body), so there is no external state to keep consistent.
*
* Priority: workflow_dispatch input `since` > latest open bot PR marker >
* last merged bot PR marker > 72h ago. Hard cap: never look back more than
* 14 days — unless the human explicitly requested a window via INPUT_SINCE.
*/
import { pathToFileURL } from "node:url"
import { appendOutput, appendSummary, repo, searchIssues } from "./lib.mjs"
const FALLBACK_HOURS = 72
const CAP_DAYS = 14
const MARKER = /<!--\s*docs-sync:\s*processed-through\s+(\S+?)\s*-->/
function extractMarker(body) {
const m = (body ?? "").match(MARKER)
if (!m) return null
const d = new Date(m[1])
return Number.isNaN(d.getTime()) ? null : d
}
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 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
}
}
}
return null
}
/**
* Apply the 14-day lookback cap. When `explicit` is true (dispatch override),
* the cap is skipped so a human-requested recovery window is not silently
* shortened. Returns `{ since, clamped }`.
*/
export function applyCap(since, now, { explicit = false } = {}) {
if (explicit) {
console.log(`14-day cap skipped: INPUT_SINCE was set explicitly (${since.toISOString()})`)
return { since, clamped: false }
}
const cap = new Date(now.getTime() - CAP_DAYS * 24 * 3600 * 1000)
if (since < cap) {
const from = since.toISOString()
const to = cap.toISOString()
console.warn(
`::warning::docs-sync watermark clamped from ${from} to ${to} (${CAP_DAYS}-day cap). ` +
`Anything still uncovered before ${to} is abandoned and needs a human.`,
)
return { since: cap, clamped: true }
}
return { since, clamped: false }
}
async function main() {
const now = new Date()
let since
let explicit = false
const input = (process.env.INPUT_SINCE ?? "").trim()
if (input) {
since = new Date(input)
if (Number.isNaN(since.getTime())) {
throw new Error(`Invalid INPUT_SINCE: ${input}`)
}
explicit = true
console.log(`watermark from dispatch input: ${since.toISOString()}`)
} else {
since = (await findWatermark()) ?? new Date(now.getTime() - FALLBACK_HOURS * 3600 * 1000)
}
// A forged, edited, or malformed marker in the future would silently match
// nothing in the merged:>= search; clamp it loudly.
if (since > now) {
console.warn(`watermark ${since.toISOString()} is in the future, clamping to now`)
since = now
}
;({ since } = applyCap(since, now, { explicit }))
appendOutput("since", since.toISOString())
appendOutput("now", now.toISOString())
appendOutput("since_override", explicit ? "true" : "false")
appendSummary(`### docs-sync watermark\n\n- since: \`${since.toISOString()}\`\n- now: \`${now.toISOString()}\`\n`)
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (isMain) {
main().catch((err) => {
console.error(err)
process.exit(1)
})
}
+3
View File
@@ -26,15 +26,18 @@ concurrency:
jobs:
call-valtown:
# kilocode_change start
if: >
github.event_name == 'workflow_dispatch' ||
(
github.repository == 'Kilo-Org/kilocode' &&
github.event.pull_request.merged == true &&
(
startsWith(github.event.pull_request.title, 'feat:') ||
startsWith(github.event.pull_request.title, 'feat(')
)
)
# kilocode_change end
runs-on: ubuntu-latest
timeout-minutes: 5
+1 -1
View File
@@ -18,7 +18,7 @@ on:
jobs:
check:
runs-on: blacksmith-2vcpu-ubuntu-2404
runs-on: blacksmith-2vcpu-ubuntu-2404 # kilocode_change
outputs:
is-member: ${{ steps.check.outputs['is-member'] }}
steps:
+1
View File
@@ -19,6 +19,7 @@ on:
jobs:
analyze:
name: Analyze (java-kotlin)
if: github.event_name == 'workflow_dispatch' || github.repository == 'Kilo-Org/kilocode'
runs-on: ubuntu-latest
permissions:
security-events: write
+1
View File
@@ -24,6 +24,7 @@ on:
jobs:
analyze:
name: Analyze (${{ matrix.language }})
if: github.event_name == 'workflow_dispatch' || github.repository == 'Kilo-Org/kilocode'
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
+1
View File
@@ -16,6 +16,7 @@ permissions:
jobs:
build:
if: github.event_name == 'workflow_dispatch' || github.repository == 'Kilo-Org/kilocode' # kilocode_change
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
# kilocode_change start
@@ -118,7 +118,7 @@ jobs:
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
recheck-compliance:
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance')
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance') # kilocode_change
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
+1 -1
View File
@@ -13,7 +13,7 @@ on:
jobs:
build:
name: Build docs site
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: blacksmith-4vcpu-ubuntu-2404 # kilocode_change
steps:
- name: Checkout repository
uses: actions/checkout@v6 # kilocode_change
+238
View File
@@ -0,0 +1,238 @@
# kilocode_change - new file
name: docs-sync
# Daily bot: collects PRs merged to Kilo-Org/cloud and Kilo-Org/kilocode,
# triages them for docs relevance, runs Kilo CLI headless to update
# packages/kilo-docs, and maintains one rolling PR for human review.
#
# Security posture: scheduled/manual runs check out the dispatched ref and may
# push/comment with write permissions and org secrets. PR runs (paths-limited to
# this workflow and .github/docs-sync/**) execute branch code only in a
# read-only, secretless `selftest` job that never pushes, comments, or calls an
# LLM. `pull_request` (not `pull_request_target`) keeps fork tokens read-only.
# State is derived from the bot's own PRs (watermark marker in the PR body), so
# missed or failed runs self-heal on the next run.
on:
schedule:
- cron: "0 7 * * *" # 07:00 UTC daily
workflow_dispatch:
inputs:
since:
description: "Override watermark (ISO date, e.g. 2026-07-20). Default: last processed-through marker, 72h fallback, 14d cap."
required: false
type: string
dry_run:
description: "Collect + triage only, no edits, no PR"
type: boolean
default: false
pull_request:
paths:
- ".github/docs-sync/**"
- ".github/workflows/docs-sync.yml"
permissions:
contents: write # push the rolling branch, create the auto-docs label
pull-requests: write # create/update the rolling PR
issues: write # comment on the rolling PR
concurrency:
group: ${{ github.event_name == 'pull_request' && format('docs-sync-pr-{0}', github.event.pull_request.number) || 'docs-sync' }}
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' }}
jobs:
selftest:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "24"
package-manager-cache: false
- name: Run docs-sync selftest
run: node .github/docs-sync/selftest.mjs
sync:
if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request'
runs-on: blacksmith-4vcpu-ubuntu-2404
# Budget: 4 setup/collect + 90 triage + 120 edit + 2 verify + 10 fix + 2 upsert = 228 min, 12-minute reserve.
# These are ceilings, not costs: a caught-up run triages ~2 chunks and edits
# ~1 batch and finishes in ~25 min. The old 35/50 pair was the binding
# constraint on backlog drain — run 30306629290 deferred 54 PRs untriaged and
# 31 unedited purely on budget, with no attempt made. See the throughput note
# in the PR description for the arithmetic.
timeout-minutes: 240
env:
# Both are required: without KILO_ORG_ID the gateway bills the key
# owner's personal balance (402 "Add credits") instead of the org.
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0 # prepare-branch merges main into the rolling branch
- name: Configure git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "24"
package-manager-cache: false
- name: Run docs-sync selftest
run: node .github/docs-sync/selftest.mjs
- name: Install Kilo CLI
run: |
npm install -g @kilocode/cli
kilo --version
- name: Resolve watermark
id: wm
env:
GH_TOKEN: ${{ github.token }}
INPUT_SINCE: ${{ inputs.since }}
run: node .github/docs-sync/watermark.mjs
- name: Collect merged PRs
id: collect
env:
GH_TOKEN: ${{ github.token }}
run: node .github/docs-sync/collect.mjs --since "${{ steps.wm.outputs.since }}"
- name: Triage merged PRs (LLM, chunked)
id: triage
if: steps.collect.outputs.count != '0'
env:
SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }}
# Default 35 fit only 8 of 11 chunks on a 254-PR window. Headroom for
# --auto making chunks slower now that the agent really runs commands.
TRIAGE_BUDGET_MINUTES: "90"
run: node .github/docs-sync/triage.mjs
- name: Filter docs-worthy PRs
id: worthy
if: steps.collect.outputs.count != '0'
run: |
node .github/docs-sync/filter-worthy.mjs \
docs-sync-out/digest-full.json docs-sync-out/triage.json docs-sync-out/worthy.json
count=$(node -p "require('./docs-sync-out/worthy.json').length")
echo "count=$count" >> "$GITHUB_OUTPUT"
if [ "$count" = "0" ]; then
echo "No docs-worthy PRs in this window; skipping edit/verify/PR."
fi
- name: Setup Bun
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
uses: ./.github/actions/setup-bun
- name: Prepare rolling branch
id: prep
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
env:
GH_TOKEN: ${{ github.token }}
run: node .github/docs-sync/prepare-branch.mjs
# After prepare-branch checks out the rolling branch and merges main, the
# worktree holds main's scripts. Restore the dispatched ref's copies so a
# branch-dispatch AC9 run actually exercises the fixed code. git restore
# (not checkout) leaves them unstaged so upsert-pr's bare commit won't
# include them in the docs PR.
- name: Restore docs-sync scripts from the dispatched ref
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
run: git restore --source=${{ github.sha }} -- .github/docs-sync
- name: Update docs (Kilo CLI, batched)
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
continue-on-error: true
env:
# Default 50 fit only 4 of 11 batches. A healthy --auto batch is ~8 min,
# and edit.mjs will not start a batch without EDIT_BATCH_TIMEOUT_MINUTES
# (15) left, so 120 covers 14 batches = 70 PRs against ~5 worthy/day.
EDIT_BUDGET_MINUTES: "120"
run: node .github/docs-sync/edit.mjs
- name: Verify docs build and tests
id: verify
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
continue-on-error: true
env:
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }}
run: |
set -o pipefail
{ bun run --filter @kilocode/kilo-docs build && bun run --filter @kilocode/kilo-docs test; } 2>&1 | tee docs-sync-out/verify.log
- name: Fix verify failures (one pass)
id: fix
if: steps.verify.outcome == 'failure'
continue-on-error: true
timeout-minutes: 10
env:
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }}
run: |
set -o pipefail
# Headless kilo run auto-rejects every permission ask; the runner has no
# 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 \
| 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"
# The rebuild below decides this step's outcome, not the agent's exit code.
# Without the guard above, `set -o pipefail` + the default `bash -e` would
# abort here once the CLI half of this PR ships: a mid-stream session error
# (or an auto-rejected ask) exits 1, verify2.log is never written, and
# `Re-verify status` reports VERIFIED=false even when the docs build fine.
{ bun run --filter @kilocode/kilo-docs build && bun run --filter @kilocode/kilo-docs test; } 2>&1 | tee docs-sync-out/verify2.log
- name: Re-verify status
id: verified
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
env:
VERIFY_OUTCOME: ${{ steps.verify.outcome }}
FIX_OUTCOME: ${{ steps.fix.outcome }}
run: |
if [ "$VERIFY_OUTCOME" = "success" ] || [ "$FIX_OUTCOME" = "success" ]; then
echo "ok=true" >> "$GITHUB_OUTPUT"
else
echo "ok=false" >> "$GITHUB_OUTPUT"
fi
- name: Upsert rolling PR
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
env:
GH_TOKEN: ${{ github.token }}
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
- name: Upload run artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: docs-sync-out
path: docs-sync-out/
retention-days: 14
if-no-files-found: ignore
+15 -5
View File
@@ -57,14 +57,24 @@ jobs:
BUILD_LOG=$(mktemp)
trap 'rm -f "$BUILD_LOG"' EXIT
# Build with fakeHash to trigger hash mismatch and reveal correct hash
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
HASH=""
MAX_ATTEMPTS=3
for ((ATTEMPT = 1; ATTEMPT <= MAX_ATTEMPTS; ATTEMPT++)); do
# Build with fakeHash to trigger hash mismatch and reveal correct hash
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
# Extract hash from build log with portability
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
[ -n "$HASH" ] && break
if [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; then
echo "::warning::Attempt ${ATTEMPT}/${MAX_ATTEMPTS} produced no hash for ${SYSTEM}; retrying in $((ATTEMPT * 10))s"
sleep $((ATTEMPT * 10))
fi
done
if [ -z "$HASH" ]; then
echo "::error::Failed to compute hash for ${SYSTEM}"
echo "::error::Failed to compute hash for ${SYSTEM} after ${MAX_ATTEMPTS} attempts"
cat "$BUILD_LOG"
exit 1
fi
@@ -0,0 +1,341 @@
# kilocode_change - new file
name: publish-jetbrains-bundled
on:
workflow_dispatch:
inputs:
pr:
description: Merged JetBrains release PR number to bundle
required: true
type: string
merge_commit:
description: Merge commit SHA from the reviewed release PR
required: true
type: string
concurrency:
group: publish-jetbrains-bundled-pr-${{ inputs.pr }}
cancel-in-progress: false
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
validate:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
pull-requests: read
outputs:
version: ${{ steps.release.outputs.version }}
kind: ${{ steps.release.outputs.kind }}
tag: ${{ steps.release.outputs.tag }}
channel: ${{ steps.release.outputs.marketplace_channel }}
steps:
- name: Checkout trusted validation scripts
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: main
- name: Setup Bun for validation
uses: ./.github/actions/setup-bun
- name: Checkout merged release PR for validation
uses: actions/checkout@v6
with:
fetch-depth: 0
path: release
persist-credentials: false
ref: ${{ inputs.merge_commit }}
- name: Validate release PR and tag
id: release
working-directory: release
run: bun ../script/jetbrains-release-validate.ts --pr "$PR_NUMBER"
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ inputs.pr }}
bundle:
needs: validate
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-8vcpu-ubuntu-2404
permissions:
actions: read
contents: write
outputs:
version: ${{ needs.validate.outputs.version }}
kind: ${{ needs.validate.outputs.kind }}
steps:
- name: Checkout merged release PR metadata
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ inputs.merge_commit }}
- name: Save reviewed release metadata
run: |
cp packages/kilo-jetbrains/CHANGELOG.md "$RUNNER_TEMP/jetbrains-CHANGELOG.md"
cp packages/kilo-jetbrains/gradle.properties "$RUNNER_TEMP/jetbrains-gradle.properties"
- name: Checkout release tag
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ needs.validate.outputs.tag }}
- name: Restore reviewed release metadata
run: |
cp "$RUNNER_TEMP/jetbrains-CHANGELOG.md" packages/kilo-jetbrains/CHANGELOG.md
cp "$RUNNER_TEMP/jetbrains-gradle.properties" packages/kilo-jetbrains/gradle.properties
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Install dependencies
run: bun install
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Install build tools
run: |
sudo apt-get update
sudo apt-get install -y patchelf zip unzip
curl --fail --location \
https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \
--output "$RUNNER_TEMP/zig.tar.xz"
echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status
tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP"
echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH"
- name: Validate signing secrets
run: |
missing=0
for name in JETBRAINS_CERTIFICATE_CHAIN JETBRAINS_PRIVATE_KEY JETBRAINS_PRIVATE_KEY_PASSWORD; do
if [[ -z "${!name}" ]]; then
echo "Missing required secret: $name" >&2
missing=1
fi
done
exit "$missing"
env:
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
- name: Write signing secrets to temp files
run: |
dir="$RUNNER_TEMP/jetbrains-signing"
mkdir -m 700 -p "$dir"
chain="$dir/certificate-chain.pem"
key="$dir/private-key.pem"
umask 077
printf '%s' "$JETBRAINS_CERTIFICATE_CHAIN" > "$chain"
printf '%s' "$JETBRAINS_PRIVATE_KEY" > "$key"
chmod 600 "$chain" "$key"
echo "JETBRAINS_CERTIFICATE_CHAIN_FILE=$chain" >> "$GITHUB_ENV"
echo "JETBRAINS_PRIVATE_KEY_FILE=$key" >> "$GITHUB_ENV"
env:
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
- name: Build signed bundled plugin
working-directory: packages/kilo-jetbrains
run: |
args=(
-Pproduction=true
-Pkilo.version="$VERSION"
-Pkilo.channel="$CHANNEL"
-Pkilo.cli.bundled=true
)
./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin "${args[@]}"
env:
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
VERSION: ${{ needs.validate.outputs.version }}
CHANNEL: ${{ needs.validate.outputs.channel }}
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
- name: Remove signing secret temp files
if: always()
run: rm -rf "$RUNNER_TEMP/jetbrains-signing"
- name: Resolve bundled archive
id: archive
run: |
mapfile -t signed < <(compgen -G "packages/kilo-jetbrains/build/distributions/*-signed.zip")
if [[ "${#signed[@]}" -ne 1 ]]; then
echo "Expected exactly one signed bundled JetBrains plugin ZIP, found ${#signed[@]}." >&2
printf '%s\n' "${signed[@]}" >&2
exit 1
fi
asset="kilo-code-${VERSION}-bundled.zip"
dest="packages/kilo-jetbrains/build/release/$asset"
mkdir -p "$(dirname "$dest")"
cp "${signed[0]}" "$dest"
echo "asset=$asset" >> "$GITHUB_OUTPUT"
echo "path=$dest" >> "$GITHUB_OUTPUT"
env:
VERSION: ${{ needs.validate.outputs.version }}
- name: Upload bundled ZIP to GitHub Release
run: gh release upload "$TAG" "$ARCHIVE" --clobber --repo "$GITHUB_REPOSITORY"
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.validate.outputs.tag }}
ARCHIVE: ${{ steps.archive.outputs.path }}
- name: Resolve bundled asset URL
id: asset
run: |
url="$(gh release view "$TAG" --json assets --jq '.assets[] | select(.name == env.ASSET) | .url' --repo "$GITHUB_REPOSITORY")"
if [[ -z "$url" ]]; then
echo "Could not resolve GitHub Release URL for $ASSET" >&2
exit 1
fi
echo "url=$url" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.validate.outputs.tag }}
ASSET: ${{ steps.archive.outputs.asset }}
- name: Generate stable plugin repository XML
if: needs.validate.outputs.kind == 'stable'
run: |
mkdir -p pages/jetbrains
python3 <<'PY'
import html
import io
import os
import zipfile
import xml.etree.ElementTree as ET
archive = os.environ["ARCHIVE"]
asset = os.environ["ASSET_URL"]
version = os.environ["VERSION"]
def plugin_xml(path):
with zipfile.ZipFile(path) as zip:
for name in zip.namelist():
if name.endswith("META-INF/plugin.xml"):
return zip.read(name)
for name in zip.namelist():
if not name.endswith(".jar"):
continue
with zipfile.ZipFile(io.BytesIO(zip.read(name))) as jar:
for item in jar.namelist():
if item.endswith("META-INF/plugin.xml"):
return jar.read(item)
raise SystemExit("bundled plugin ZIP did not contain META-INF/plugin.xml")
root = ET.fromstring(plugin_xml(archive))
def text(name, default=""):
item = root.find(name)
return item.text.strip() if item is not None and item.text else default
def cdata(value):
return "<![CDATA[" + value.replace("]]>", "]]]]><![CDATA[>") + "]]>"
plugin = text("id", "ai.kilocode.jetbrains")
name = text("name", "Kilo Code")
vendor = text("vendor", "Kilo Code")
desc = text("description")
notes = text("change-notes")
idea = root.find("idea-version")
attrs = ""
if idea is not None:
since = idea.attrib.get("since-build")
until = idea.attrib.get("until-build")
if since:
attrs += f' since-build="{html.escape(since)}"'
if until:
attrs += f' until-build="{html.escape(until)}"'
xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<plugins>',
f' <plugin id="{html.escape(plugin)}" version="{html.escape(version)}" url="{html.escape(asset)}">',
f' <name>{html.escape(name)}</name>',
f' <vendor>{html.escape(vendor)}</vendor>',
f' <idea-version{attrs}/>',
]
if desc:
xml.append(f' <description>{cdata(desc)}</description>')
if notes:
xml.append(f' <change-notes>{cdata(notes)}</change-notes>')
xml.extend([' </plugin>', '</plugins>', ''])
with open("pages/jetbrains/updatePlugins.xml", "w", encoding="utf-8") as file:
file.write("\n".join(xml))
PY
env:
ARCHIVE: ${{ steps.archive.outputs.path }}
ASSET_URL: ${{ steps.asset.outputs.url }}
VERSION: ${{ needs.validate.outputs.version }}
- name: Upload stable Pages source
if: needs.validate.outputs.kind == 'stable'
uses: actions/upload-artifact@v4
with:
name: jetbrains-pages-${{ needs.validate.outputs.version }}
path: pages
if-no-files-found: error
- name: Upload workflow artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: kilo-jetbrains-bundled-${{ needs.validate.outputs.version }}
path: |
packages/kilo-jetbrains/build/release/*.zip
pages/jetbrains/updatePlugins.xml
if-no-files-found: ignore
pages:
needs: bundle
if: needs.bundle.outputs.kind == 'stable'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
actions: read
id-token: write
pages: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Download stable Pages source
uses: actions/download-artifact@v4
with:
name: jetbrains-pages-${{ needs.bundle.outputs.version }}
path: pages
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v4
with:
path: pages
- name: Deploy Pages
id: deployment
uses: actions/deploy-pages@v4
+14
View File
@@ -23,6 +23,7 @@ concurrency:
cancel-in-progress: false
permissions:
actions: write
contents: write
pull-requests: read
@@ -199,6 +200,19 @@ jobs:
ARCHIVE: ${{ steps.archive.outputs.path }}
NOTES: packages/kilo-jetbrains/build/release-notes.md
- name: Dispatch bundled GitHub release build
continue-on-error: true
run: |
gh workflow run publish-jetbrains-bundled.yml \
--repo "$GITHUB_REPOSITORY" \
--ref main \
-f pr="$PR_NUMBER" \
-f merge_commit="$MERGE_COMMIT"
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr }}
MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha || inputs.merge_commit }}
- name: Upload workflow artifact
if: always()
uses: actions/upload-artifact@v4
+1 -1
View File
@@ -258,7 +258,7 @@ jobs:
name: Validate CLI (windows-${{ matrix.arch }})
needs: build-cli
runs-on: ${{ matrix.runner }}
timeout-minutes: 20
timeout-minutes: 20 # kilocode_change
strategy:
fail-fast: false
matrix:
+17 -4
View File
@@ -42,8 +42,6 @@ jobs:
needs: changes
if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.jetbrains == 'true'
runs-on: blacksmith-4vcpu-ubuntu-2404
container:
image: ghcr.io/kilo-org/build/jetbrains:24.04
defaults:
run:
shell: bash
@@ -56,12 +54,27 @@ jobs:
- name: Mark workspace as git-safe
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Install dependencies
run: bun install
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: package.json
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Run JetBrains unit tests
run: bun script/test-ci.ts
working-directory: packages/kilo-jetbrains
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish JetBrains unit reports
if: always()
+1 -1
View File
@@ -21,7 +21,7 @@ on:
jobs:
unit:
name: unit tests
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: blacksmith-4vcpu-ubuntu-2404 # kilocode_change
defaults:
run:
shell: bash

Some files were not shown because too many files have changed in this diff Show More