CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
advancement-scanner.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| d199847 | 1 | /** |
| 2 | * Advancement Scanner — weekly Claude-driven scan for "what we should | |
| 3 | * ship next". | |
| 4 | * | |
| 5 | * Four independent best-effort probes: | |
| 6 | * | |
| 7 | * 1. **Model releases** — pull a small curated list of currently-known | |
| 8 | * Claude models (kept in code; cheap and offline), compare against | |
| 9 | * the model the platform is wired to (`MODEL_SONNET` / `MODEL_HAIKU` | |
| 10 | * in `ai-client.ts`). Newer/cheaper alternatives become a finding. | |
| 11 | * | |
| 12 | * 2. **Stack version scan** — read `package.json` from disk, fetch | |
| 13 | * each major dependency's latest npm version, flag anything >1 | |
| 14 | * major behind. Dependencies that look like a trivial bump are | |
| 15 | * handed straight to `proposeMajorMigration` for an auto-PR; the | |
| 16 | * rest become advisory findings. | |
| 17 | * | |
| 18 | * 3. **Self-improvement opportunities** — feed the last 7 days of | |
| 19 | * `audit_log` + `platform_deploys` summaries to Claude and ask | |
| 20 | * "what slow/broken/painful patterns recur?". Claude returns a | |
| 21 | * JSON array of suggested improvements. | |
| 22 | * | |
| 23 | * 4. **Trending features** — a curated in-code list of "what | |
| 24 | * competitors shipped this week" is summarised through Claude with | |
| 25 | * "which of these should Gluecron prioritize?". | |
| 26 | * | |
| 27 | * Each finding is persisted in two ways: | |
| 28 | * - As an `audit_log` row under `ai.advancement.finding` (always). | |
| 29 | * - As an issue on the self-host repo under the `ai:advancement` | |
| 30 | * label (when `openIssue` returns a number). | |
| 31 | * | |
| 32 | * Dedupe is by sha256(title) within a 30-day window. Stable marker | |
| 33 | * `<!-- gluecron:advancement:dedupe=... -->` is embedded in the issue | |
| 34 | * body so a single LIKE query catches repeats. | |
| 35 | * | |
| 36 | * Wired into autopilot as the `advancement-scanner` task (cadence-gated | |
| 37 | * to once a week, default Monday 08:00 UTC). | |
| 38 | */ | |
| 39 | ||
| 40 | import { createHash } from "crypto"; | |
| 41 | import { readFile } from "fs/promises"; | |
| 42 | import { join } from "path"; | |
| 43 | import { and, desc, eq, gte, like } from "drizzle-orm"; | |
| 44 | import { db } from "../db"; | |
| 45 | import { | |
| 46 | auditLog, | |
| 47 | issueLabels, | |
| 48 | issues, | |
| 49 | labels, | |
| 50 | repositories, | |
| 51 | users, | |
| 52 | } from "../db/schema"; | |
| 53 | import { platformDeploys } from "../db/schema-deploys"; | |
| 54 | import { | |
| 55 | MODEL_HAIKU, | |
| 56 | MODEL_SONNET, | |
| 57 | extractText, | |
| 58 | getAnthropic, | |
| 59 | isAiAvailable, | |
| 60 | parseJsonResponse, | |
| 61 | } from "./ai-client"; | |
| 62 | import { audit } from "./notify"; | |
| 63 | import { parseManifest } from "./dep-updater"; | |
| 64 | import { | |
| 65 | proposeMajorMigration, | |
| 66 | recentlyProposed, | |
| 67 | detectMajorBump, | |
| 68 | } from "./migration-assistant"; | |
| 69 | import { resolveRef } from "../git/repository"; | |
| 70 | ||
| 71 | // --------------------------------------------------------------------------- | |
| 72 | // Constants | |
| 73 | // --------------------------------------------------------------------------- | |
| 74 | ||
| 75 | export const ADVANCEMENT_AUDIT_ACTION = "ai.advancement.finding"; | |
| 76 | export const ADVANCEMENT_SCAN_COMPLETE_ACTION = "ai.advancement.scan_complete"; | |
| 77 | export const ADVANCEMENT_LABEL_NAME = "ai:advancement"; | |
| 78 | export const ADVANCEMENT_DEDUPE_MARKER_PREFIX = | |
| 79 | "<!-- gluecron:advancement:dedupe="; | |
| 80 | export const ADVANCEMENT_DEDUPE_MARKER_SUFFIX = " -->"; | |
| 81 | ||
| 82 | /** Dedupe horizon — same title (sha256) won't be re-proposed for 30 days. */ | |
| 83 | export const ADVANCEMENT_DEDUPE_DAYS = 30; | |
| 84 | ||
| 85 | /** Hard cap on findings persisted per scan (runaway protection). */ | |
| 86 | export const MAX_ADVANCEMENT_FINDINGS_PER_SCAN = 12; | |
| 87 | ||
| 88 | /** Default self-host repo when SELF_HOST_REPO is not configured. */ | |
| 89 | export const ADVANCEMENT_DEFAULT_SELF_HOST_REPO = "ccantynz-alt/Gluecron.com"; | |
| 90 | ||
| 91 | /** Lookback for the self-improvement signal — last 7 days. */ | |
| 92 | export const ADVANCEMENT_SELF_IMPROVE_LOOKBACK_DAYS = 7; | |
| 93 | ||
| 94 | // --------------------------------------------------------------------------- | |
| 95 | // Public types | |
| 96 | // --------------------------------------------------------------------------- | |
| 97 | ||
| 98 | export type AdvancementKind = | |
| 99 | | "model_release" | |
| 100 | | "stack_bump" | |
| 101 | | "self_improvement" | |
| 102 | | "trending_feature"; | |
| 103 | ||
| 104 | export type AdvancementUrgency = "low" | "medium" | "high"; | |
| 105 | ||
| 106 | export interface AdvancementFinding { | |
| 107 | kind: AdvancementKind; | |
| 108 | title: string; | |
| 109 | urgency: AdvancementUrgency; | |
| 110 | suggested_action: string; | |
| 111 | /** Optional rich body (markdown). When absent we synthesize from action. */ | |
| 112 | body_markdown?: string; | |
| 113 | /** When a stack_bump finding can be auto-PR'd, this carries the dep+target. */ | |
| 114 | bump?: { | |
| 115 | dependency: string; | |
| 116 | fromVersion: string; | |
| 117 | toVersion: string; | |
| 118 | } | null; | |
| 119 | } | |
| 120 | ||
| 121 | export interface AdvancementScanResult { | |
| 122 | findings: AdvancementFinding[]; | |
| 123 | /** Issues we actually opened on the self-host repo. */ | |
| 124 | openedIssues: number; | |
| 125 | /** Stack-bump PRs the migration assistant kicked off. */ | |
| 126 | openedPrs: number; | |
| 127 | /** Findings skipped because they were filed in the dedupe window. */ | |
| 128 | skippedDedupe: number; | |
| 129 | /** Per-probe failure count — bookkeeping for autopilot summary lines. */ | |
| 130 | errors: number; | |
| 131 | } | |
| 132 | ||
| 133 | // --------------------------------------------------------------------------- | |
| 134 | // Curated reference data — kept in code so we have a deterministic baseline | |
| 135 | // even when the upstream sources are flaky / offline. | |
| 136 | // --------------------------------------------------------------------------- | |
| 137 | ||
| 138 | /** | |
| 139 | * Known Claude models with their cost + capability hints. Update this | |
| 140 | * list as Anthropic ships new models — the scanner compares against the | |
| 141 | * currently-wired model in `ai-client.ts` to suggest upgrades. | |
| 142 | * | |
| 143 | * Cost units are arbitrary "relative dollars per million tokens" — only | |
| 144 | * the ordering matters for the comparison. Capability is also a relative | |
| 145 | * score; higher = stronger. | |
| 146 | */ | |
| 147 | export interface ClaudeModelEntry { | |
| 148 | id: string; | |
| 149 | family: "sonnet" | "haiku" | "opus"; | |
| 150 | generation: number; | |
| 151 | /** Higher = better/newer. Used to detect "newer than configured". */ | |
| 152 | capability: number; | |
| 153 | /** Lower = cheaper. Used to detect "cheaper than configured". */ | |
| 154 | cost: number; | |
| 155 | /** Human-friendly label for the finding body. */ | |
| 156 | label: string; | |
| 157 | } | |
| 158 | ||
| 159 | /** | |
| 160 | * Default curated list. Centralised so tests can pin a copy or override | |
| 161 | * via the `models` dep. The capability/cost numbers are tuned so the | |
| 162 | * scanner's logic is deterministic but they're not a fact-table — when | |
| 163 | * a new model ships, bump this list. | |
| 164 | */ | |
| 165 | export const KNOWN_CLAUDE_MODELS: ClaudeModelEntry[] = [ | |
| 166 | // Sonnet 4 family | |
| 167 | { id: "claude-sonnet-4-20250514", family: "sonnet", generation: 4, capability: 80, cost: 30, label: "Claude Sonnet 4 (May 2025)" }, | |
| 168 | { id: "claude-sonnet-4-5", family: "sonnet", generation: 4.5, capability: 86, cost: 30, label: "Claude Sonnet 4.5" }, | |
| 169 | { id: "claude-sonnet-4-6", family: "sonnet", generation: 4.6, capability: 90, cost: 30, label: "Claude Sonnet 4.6" }, | |
| 170 | { id: "claude-sonnet-4-7", family: "sonnet", generation: 4.7, capability: 92, cost: 30, label: "Claude Sonnet 4.7" }, | |
| 171 | // Haiku 4 family | |
| 172 | { id: "claude-haiku-4-5-20251001", family: "haiku", generation: 4.5, capability: 60, cost: 10, label: "Claude Haiku 4.5 (Oct 2025)" }, | |
| 173 | { id: "claude-haiku-4-7", family: "haiku", generation: 4.7, capability: 70, cost: 8, label: "Claude Haiku 4.7" }, | |
| 174 | // Opus 4 family | |
| 175 | { id: "claude-opus-4-7", family: "opus", generation: 4.7, capability: 100, cost: 60, label: "Claude Opus 4.7" }, | |
| 176 | ]; | |
| 177 | ||
| 178 | /** | |
| 179 | * Curated competitor-features list. Update as you spot relevant launches. | |
| 180 | * The point of this list is to be the prompt context that lets Claude | |
| 181 | * pick the few worth prioritising. Keep entries short + factual. | |
| 182 | */ | |
| 183 | export const TRENDING_FEATURE_CATALOGUE: Array<{ source: string; feature: string }> = [ | |
| 184 | { source: "GitHub", feature: "Repo-level AI custom instructions for Copilot reviewers." }, | |
| 185 | { source: "GitLab", feature: "Per-MR auto-summary that updates as new commits land." }, | |
| 186 | { source: "Linear", feature: "Cycle-end AI velocity reports posted into Slack." }, | |
| 187 | { source: "Gitea", feature: "Built-in OIDC issuer for CI runners; remove static PATs." }, | |
| 188 | { source: "Sentry", feature: "Auto-resolve issues when the suspect commit is reverted." }, | |
| 189 | { source: "Vercel", feature: "Preview deploys carry per-PR observability dashboards." }, | |
| 190 | { source: "Fly.io", feature: "Built-in rolling restart with health-gate per machine." }, | |
| 191 | { source: "Render", feature: "Auto-rollback when post-deploy synthetic check goes red." }, | |
| 192 | ]; | |
| 193 | ||
| 194 | // --------------------------------------------------------------------------- | |
| 195 | // Helpers — pure | |
| 196 | // --------------------------------------------------------------------------- | |
| 197 | ||
| 198 | /** sha256 of the title — deterministic dedupe key. */ | |
| 199 | export function advancementDedupeKey(title: string): string { | |
| 200 | return createHash("sha256") | |
| 201 | .update(title.trim().toLowerCase()) | |
| 202 | .digest("hex") | |
| 203 | .slice(0, 32); | |
| 204 | } | |
| 205 | ||
| 206 | function dedupeMarker(key: string): string { | |
| 207 | return `${ADVANCEMENT_DEDUPE_MARKER_PREFIX}${key}${ADVANCEMENT_DEDUPE_MARKER_SUFFIX}`; | |
| 208 | } | |
| 209 | ||
| 210 | /** | |
| 211 | * Render the issue body markdown for an advancement finding. Pure helper | |
| 212 | * — exported so tests can pin the format. | |
| 213 | */ | |
| 214 | export function renderAdvancementBody( | |
| 215 | finding: AdvancementFinding, | |
| 216 | dedupeKey: string | |
| 217 | ): string { | |
| 218 | const urgencyBadge = | |
| 219 | finding.urgency === "high" | |
| 220 | ? "**Urgency:** :red_circle: high" | |
| 221 | : finding.urgency === "medium" | |
| 222 | ? "**Urgency:** :large_orange_diamond: medium" | |
| 223 | : "**Urgency:** :white_circle: low"; | |
| 224 | const kindLabel = | |
| 225 | finding.kind === "model_release" | |
| 226 | ? "Model release" | |
| 227 | : finding.kind === "stack_bump" | |
| 228 | ? "Stack version bump" | |
| 229 | : finding.kind === "self_improvement" | |
| 230 | ? "Self-improvement opportunity" | |
| 231 | : "Trending feature"; | |
| 232 | const body = | |
| 233 | finding.body_markdown?.trim() || finding.suggested_action.trim(); | |
| 234 | return [ | |
| 235 | dedupeMarker(dedupeKey), | |
| 236 | "_Filed automatically by the Gluecron AI advancement scanner._", | |
| 237 | "", | |
| 238 | `**Kind:** ${kindLabel}`, | |
| 239 | urgencyBadge, | |
| 240 | "", | |
| 241 | "### Suggested action", | |
| 242 | finding.suggested_action.trim(), | |
| 243 | "", | |
| 244 | "### Details", | |
| 245 | body, | |
| 246 | "", | |
| 247 | "---", | |
| 248 | `_Dedupe key: \`${dedupeKey}\`. The same advancement will not be re-filed for ${ADVANCEMENT_DEDUPE_DAYS} days._`, | |
| 249 | ].join("\n"); | |
| 250 | } | |
| 251 | ||
| 252 | /** | |
| 253 | * Compare a configured model id against the curated list and return up to | |
| 254 | * two suggestions: a strict upgrade (more capable, same/lower cost) and a | |
| 255 | * cheaper-better alternative (more capable AND cheaper) if available. | |
| 256 | * Pure — no I/O. | |
| 257 | */ | |
| 258 | export function suggestModelUpgrades( | |
| 259 | configuredId: string, | |
| 260 | catalogue: ClaudeModelEntry[] = KNOWN_CLAUDE_MODELS | |
| 261 | ): AdvancementFinding[] { | |
| 262 | const current = catalogue.find((m) => m.id === configuredId); | |
| 263 | if (!current) { | |
| 264 | // Configured model isn't in our catalogue → it might be a retired ID. | |
| 265 | // Suggest the strongest in the same family if we can guess it. | |
| 266 | const familyGuess: ClaudeModelEntry["family"] | null = configuredId.includes("opus") | |
| 267 | ? "opus" | |
| 268 | : configuredId.includes("haiku") | |
| 269 | ? "haiku" | |
| 270 | : configuredId.includes("sonnet") | |
| 271 | ? "sonnet" | |
| 272 | : null; | |
| 273 | if (!familyGuess) return []; | |
| 274 | const best = catalogue | |
| 275 | .filter((m) => m.family === familyGuess) | |
| 276 | .sort((a, b) => b.capability - a.capability)[0]; | |
| 277 | if (!best || best.id === configuredId) return []; | |
| 278 | return [ | |
| 279 | { | |
| 280 | kind: "model_release", | |
| 281 | title: `Upgrade configured Claude model to ${best.label}`, | |
| 282 | urgency: "medium", | |
| 283 | suggested_action: `Replace \`${configuredId}\` with \`${best.id}\` in ai-client.ts (and any pinned references). Catalogue entry: ${best.label}.`, | |
| 284 | }, | |
| 285 | ]; | |
| 286 | } | |
| 287 | ||
| 288 | const findings: AdvancementFinding[] = []; | |
| 289 | const sameFamilyBetter = catalogue | |
| 290 | .filter((m) => m.family === current.family && m.capability > current.capability) | |
| 291 | .sort((a, b) => b.capability - a.capability); | |
| 292 | if (sameFamilyBetter[0]) { | |
| 293 | const target = sameFamilyBetter[0]; | |
| 294 | findings.push({ | |
| 295 | kind: "model_release", | |
| 296 | title: `Upgrade ${current.label} → ${target.label}`, | |
| 297 | urgency: "medium", | |
| 298 | suggested_action: `Bump \`${current.id}\` to \`${target.id}\` in ai-client.ts. ${target.label} is the latest in the ${current.family} family and is +${target.capability - current.capability} capability over the currently-configured model.`, | |
| 299 | }); | |
| 300 | } | |
| 301 | ||
| 302 | const cheaperBetter = catalogue | |
| 303 | .filter( | |
| 304 | (m) => | |
| 305 | m.family === current.family && | |
| 306 | m.capability >= current.capability && | |
| 307 | m.cost < current.cost && | |
| 308 | m.id !== current.id | |
| 309 | ) | |
| 310 | .sort((a, b) => a.cost - b.cost); | |
| 311 | if (cheaperBetter[0]) { | |
| 312 | const target = cheaperBetter[0]; | |
| 313 | findings.push({ | |
| 314 | kind: "model_release", | |
| 315 | title: `Save cost by moving to ${target.label}`, | |
| 316 | urgency: "low", | |
| 317 | suggested_action: `Consider \`${target.id}\` — same/better capability at lower cost (${target.cost} vs ${current.cost} relative units).`, | |
| 318 | }); | |
| 319 | } | |
| 320 | ||
| 321 | return findings; | |
| 322 | } | |
| 323 | ||
| 324 | // --------------------------------------------------------------------------- | |
| 325 | // Helpers — I/O | |
| 326 | // --------------------------------------------------------------------------- | |
| 327 | ||
| 328 | /** Read this repo's own package.json from disk. Returns null on failure. */ | |
| 329 | export async function readLocalPackageJson(): Promise<string | null> { | |
| 330 | try { | |
| 331 | const path = join(process.cwd(), "package.json"); | |
| 332 | return await readFile(path, "utf8"); | |
| 333 | } catch { | |
| 334 | return null; | |
| 335 | } | |
| 336 | } | |
| 337 | ||
| 338 | /** | |
| 339 | * Fetch the latest version of a package from npm. Best-effort with 5s | |
| 340 | * timeout — never throws. | |
| 341 | */ | |
| 342 | export async function fetchNpmLatest(pkg: string): Promise<string | null> { | |
| 343 | try { | |
| 344 | const ctrl = new AbortController(); | |
| 345 | const t = setTimeout(() => ctrl.abort(), 5000); | |
| 346 | try { | |
| 347 | const safe = encodeURIComponent(pkg).replace(/%40/g, "@"); | |
| 348 | const res = await fetch(`https://registry.npmjs.org/${safe}/latest`, { | |
| 349 | headers: { accept: "application/json" }, | |
| 350 | signal: ctrl.signal, | |
| 351 | }); | |
| 352 | if (!res.ok) return null; | |
| 353 | const data = (await res.json()) as { version?: unknown }; | |
| 354 | return typeof data.version === "string" ? data.version : null; | |
| 355 | } finally { | |
| 356 | clearTimeout(t); | |
| 357 | } | |
| 358 | } catch { | |
| 359 | return null; | |
| 360 | } | |
| 361 | } | |
| 362 | ||
| 363 | // --------------------------------------------------------------------------- | |
| 364 | // Dependency injection seam | |
| 365 | // --------------------------------------------------------------------------- | |
| 366 | ||
| 367 | export interface AdvancementScanDeps { | |
| 368 | /** Override AI key check (DI for tests). */ | |
| 369 | aiAvailable?: () => boolean; | |
| 370 | /** Override the package.json reader. */ | |
| 371 | loadPackageJson?: () => Promise<string | null>; | |
| 372 | /** Override the npm-latest lookup. */ | |
| 373 | fetchLatestVersion?: (name: string) => Promise<string | null>; | |
| 374 | /** Override the curated model catalogue. */ | |
| 375 | modelCatalogue?: ClaudeModelEntry[]; | |
| 376 | /** Override the configured-models reader (returns the ids we'd compare). */ | |
| 377 | configuredModels?: () => string[]; | |
| 378 | /** Override the self-improvement Claude call. */ | |
| 379 | askSelfImprovement?: ( | |
| 380 | summary: string | |
| 381 | ) => Promise<AdvancementFinding[]>; | |
| 382 | /** Override the trending-features Claude call. */ | |
| 383 | askTrending?: ( | |
| 384 | catalogue: typeof TRENDING_FEATURE_CATALOGUE | |
| 385 | ) => Promise<AdvancementFinding[]>; | |
| 386 | /** Override the self-host repo resolver. */ | |
| 387 | resolveSelfHostRepo?: () => Promise<{ | |
| 388 | repositoryId: string; | |
| 389 | ownerId: string; | |
| 390 | ownerName: string; | |
| 391 | repoName: string; | |
| 392 | defaultBranch: string | null; | |
| 393 | } | null>; | |
| 394 | /** Override dedupe lookup. */ | |
| 395 | isDuplicate?: ( | |
| 396 | repositoryId: string, | |
| 397 | dedupeKey: string, | |
| 398 | days: number | |
| 399 | ) => Promise<boolean>; | |
| 400 | /** Override issue creation. */ | |
| 401 | openIssue?: (args: { | |
| 402 | repositoryId: string; | |
| 403 | authorId: string; | |
| 404 | title: string; | |
| 405 | body: string; | |
| 406 | }) => Promise<number | null>; | |
| 407 | /** Override audit writer. */ | |
| 408 | recordAudit?: ( | |
| 409 | finding: AdvancementFinding, | |
| 410 | repositoryId: string | null, | |
| 411 | issueNumber: number | null, | |
| 412 | dedupeKey: string | |
| 413 | ) => Promise<void>; | |
| 414 | /** Override the migration-assistant kickoff for stack bumps. */ | |
| 415 | proposeBumpPr?: (args: { | |
| 416 | repositoryId: string; | |
| 417 | dependency: string; | |
| 418 | fromVersion: string; | |
| 419 | toVersion: string; | |
| 420 | baseSha: string; | |
| 421 | }) => Promise<{ branch: string; prNumber: number } | null>; | |
| 422 | /** | |
| 423 | * Override the base-sha resolver for stack bumps. Tests pass a fixed | |
| 424 | * value to avoid spawning git. Default reads the default branch HEAD. | |
| 425 | */ | |
| 426 | resolveBaseSha?: ( | |
| 427 | ownerName: string, | |
| 428 | repoName: string, | |
| 429 | branch: string | |
| 430 | ) => Promise<string | null>; | |
| 431 | /** Override the per-scan cap. */ | |
| 432 | maxFindings?: number; | |
| 433 | /** Override the audit-trail "scan complete" writer. */ | |
| 434 | recordScanComplete?: (result: AdvancementScanResult) => Promise<void>; | |
| 435 | } | |
| 436 | ||
| 437 | // --------------------------------------------------------------------------- | |
| 438 | // Default implementations of the DI seams | |
| 439 | // --------------------------------------------------------------------------- | |
| 440 | ||
| 441 | function defaultConfiguredModels(): string[] { | |
| 442 | return [MODEL_SONNET, MODEL_HAIKU]; | |
| 443 | } | |
| 444 | ||
| 445 | async function defaultAskSelfImprovement( | |
| 446 | summary: string | |
| 447 | ): Promise<AdvancementFinding[]> { | |
| 448 | try { | |
| 449 | const client = getAnthropic(); | |
| 450 | const message = await client.messages.create({ | |
| 451 | model: MODEL_SONNET, | |
| 452 | max_tokens: 1500, | |
| 453 | messages: [ | |
| 454 | { | |
| 455 | role: "user", | |
| 456 | content: `You are reviewing Gluecron's last ${ADVANCEMENT_SELF_IMPROVE_LOOKBACK_DAYS} days of platform telemetry. Identify recurring slow / broken / painful patterns and suggest concrete improvements. | |
| 457 | ||
| 458 | Respond ONLY with JSON of the form: | |
| 459 | ||
| 460 | {"findings": [ | |
| 461 | { | |
| 462 | "title": "<one-line summary, leading verb>", | |
| 463 | "urgency": "low" | "medium" | "high", | |
| 464 | "suggested_action": "<single concrete next step the team can take>", | |
| 465 | "body_markdown": "<optional 1-3 paragraphs of context>" | |
| 466 | } | |
| 467 | ]} | |
| 468 | ||
| 469 | Limit to the top 3 highest-leverage improvements. Return {"findings": []} if nothing stands out — silence is correct for a healthy platform. | |
| 470 | ||
| 471 | Telemetry summary: | |
| 472 | ||
| 473 | ${summary}`, | |
| 474 | }, | |
| 475 | ], | |
| 476 | }); | |
| 477 | const parsed = parseJsonResponse<{ findings: AdvancementFinding[] }>( | |
| 478 | extractText(message) | |
| 479 | ); | |
| 480 | if (!parsed || !Array.isArray(parsed.findings)) return []; | |
| 481 | return parsed.findings | |
| 482 | .filter(isPlausibleClaudeFinding) | |
| 483 | .map((f) => ({ ...f, kind: "self_improvement" as const })); | |
| 484 | } catch (err) { | |
| 485 | console.warn( | |
| 486 | "[advancement-scanner] self-improvement Claude call failed:", | |
| 487 | err instanceof Error ? err.message : err | |
| 488 | ); | |
| 489 | return []; | |
| 490 | } | |
| 491 | } | |
| 492 | ||
| 493 | async function defaultAskTrending( | |
| 494 | catalogue: typeof TRENDING_FEATURE_CATALOGUE | |
| 495 | ): Promise<AdvancementFinding[]> { | |
| 496 | try { | |
| 497 | const client = getAnthropic(); | |
| 498 | const list = catalogue | |
| 499 | .map((c, i) => `${i + 1}. [${c.source}] ${c.feature}`) | |
| 500 | .join("\n"); | |
| 501 | const message = await client.messages.create({ | |
| 502 | model: MODEL_SONNET, | |
| 503 | max_tokens: 1200, | |
| 504 | messages: [ | |
| 505 | { | |
| 506 | role: "user", | |
| 507 | content: `Below is a curated list of features competitors in the dev-platform space shipped recently. Which 0-3 of these should Gluecron (an AI-native, self-hostable git platform) prioritize next? | |
| 508 | ||
| 509 | Respond ONLY with JSON: | |
| 510 | ||
| 511 | {"findings": [ | |
| 512 | { | |
| 513 | "title": "<verb-leading one-line proposal>", | |
| 514 | "urgency": "low" | "medium" | "high", | |
| 515 | "suggested_action": "<concrete first step>", | |
| 516 | "body_markdown": "<1-2 paragraphs of rationale>" | |
| 517 | } | |
| 518 | ]} | |
| 519 | ||
| 520 | Return {"findings": []} if none of them are a good fit. | |
| 521 | ||
| 522 | Catalogue: | |
| 523 | ${list}`, | |
| 524 | }, | |
| 525 | ], | |
| 526 | }); | |
| 527 | const parsed = parseJsonResponse<{ findings: AdvancementFinding[] }>( | |
| 528 | extractText(message) | |
| 529 | ); | |
| 530 | if (!parsed || !Array.isArray(parsed.findings)) return []; | |
| 531 | return parsed.findings | |
| 532 | .filter(isPlausibleClaudeFinding) | |
| 533 | .map((f) => ({ ...f, kind: "trending_feature" as const })); | |
| 534 | } catch (err) { | |
| 535 | console.warn( | |
| 536 | "[advancement-scanner] trending Claude call failed:", | |
| 537 | err instanceof Error ? err.message : err | |
| 538 | ); | |
| 539 | return []; | |
| 540 | } | |
| 541 | } | |
| 542 | ||
| 543 | /** Validate Claude's per-finding shape — drop garbage rows quietly. */ | |
| 544 | function isPlausibleClaudeFinding(f: unknown): f is AdvancementFinding { | |
| 545 | if (!f || typeof f !== "object") return false; | |
| 546 | const x = f as Record<string, unknown>; | |
| 547 | if (typeof x.title !== "string" || x.title.trim().length === 0) return false; | |
| 548 | if (typeof x.suggested_action !== "string") return false; | |
| 549 | if ( | |
| 550 | x.urgency !== "low" && | |
| 551 | x.urgency !== "medium" && | |
| 552 | x.urgency !== "high" | |
| 553 | ) { | |
| 554 | return false; | |
| 555 | } | |
| 556 | return true; | |
| 557 | } | |
| 558 | ||
| 559 | async function defaultResolveSelfHostRepo(): Promise<{ | |
| 560 | repositoryId: string; | |
| 561 | ownerId: string; | |
| 562 | ownerName: string; | |
| 563 | repoName: string; | |
| 564 | defaultBranch: string | null; | |
| 565 | } | null> { | |
| 566 | const fullName = | |
| 567 | process.env.SELF_HOST_REPO || ADVANCEMENT_DEFAULT_SELF_HOST_REPO; | |
| 568 | const [ownerName, repoName] = fullName.includes("/") | |
| 569 | ? fullName.split("/") | |
| 570 | : [fullName, "Gluecron.com"]; | |
| 571 | try { | |
| 572 | const [row] = await db | |
| 573 | .select({ | |
| 574 | repositoryId: repositories.id, | |
| 575 | ownerId: repositories.ownerId, | |
| 576 | defaultBranch: repositories.defaultBranch, | |
| 577 | }) | |
| 578 | .from(repositories) | |
| 579 | .innerJoin(users, eq(users.id, repositories.ownerId)) | |
| 580 | .where(and(eq(users.username, ownerName), eq(repositories.name, repoName))) | |
| 581 | .limit(1); | |
| 582 | if (!row) return null; | |
| 583 | return { | |
| 584 | repositoryId: row.repositoryId, | |
| 585 | ownerId: row.ownerId, | |
| 586 | ownerName, | |
| 587 | repoName, | |
| 588 | defaultBranch: row.defaultBranch ?? null, | |
| 589 | }; | |
| 590 | } catch (err) { | |
| 591 | console.warn( | |
| 592 | "[advancement-scanner] self-host repo resolve failed:", | |
| 593 | err instanceof Error ? err.message : err | |
| 594 | ); | |
| 595 | return null; | |
| 596 | } | |
| 597 | } | |
| 598 | ||
| 599 | async function defaultIsDuplicate( | |
| 600 | repositoryId: string, | |
| 601 | dedupeKey: string, | |
| 602 | days: number | |
| 603 | ): Promise<boolean> { | |
| 604 | const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000); | |
| 605 | try { | |
| 606 | const [row] = await db | |
| 607 | .select({ id: issues.id }) | |
| 608 | .from(issues) | |
| 609 | .where( | |
| 610 | and( | |
| 611 | eq(issues.repositoryId, repositoryId), | |
| 612 | gte(issues.createdAt, cutoff), | |
| 613 | like(issues.body, `%${dedupeMarker(dedupeKey)}%`) | |
| 614 | ) | |
| 615 | ) | |
| 616 | .limit(1); | |
| 617 | return !!row; | |
| 618 | } catch (err) { | |
| 619 | console.warn( | |
| 620 | "[advancement-scanner] dedupe lookup failed:", | |
| 621 | err instanceof Error ? err.message : err | |
| 622 | ); | |
| 623 | // Fail-closed: treat as duplicate so we don't double-file on a flaky DB. | |
| 624 | return true; | |
| 625 | } | |
| 626 | } | |
| 627 | ||
| 628 | async function ensureAdvancementLabel( | |
| 629 | repositoryId: string | |
| 630 | ): Promise<string | null> { | |
| 631 | try { | |
| 632 | const [existing] = await db | |
| 633 | .select({ id: labels.id }) | |
| 634 | .from(labels) | |
| 635 | .where( | |
| 636 | and( | |
| 637 | eq(labels.repositoryId, repositoryId), | |
| 638 | eq(labels.name, ADVANCEMENT_LABEL_NAME) | |
| 639 | ) | |
| 640 | ) | |
| 641 | .limit(1); | |
| 642 | if (existing) return existing.id; | |
| 643 | const [inserted] = await db | |
| 644 | .insert(labels) | |
| 645 | .values({ | |
| 646 | repositoryId, | |
| 647 | name: ADVANCEMENT_LABEL_NAME, | |
| 648 | color: "#36c5d6", | |
| 649 | description: "Auto-filed by the AI advancement scanner.", | |
| 650 | }) | |
| 651 | .returning({ id: labels.id }); | |
| 652 | return inserted?.id ?? null; | |
| 653 | } catch (err) { | |
| 654 | console.warn( | |
| 655 | "[advancement-scanner] label ensure failed:", | |
| 656 | err instanceof Error ? err.message : err | |
| 657 | ); | |
| 658 | return null; | |
| 659 | } | |
| 660 | } | |
| 661 | ||
| 662 | async function defaultOpenIssue(args: { | |
| 663 | repositoryId: string; | |
| 664 | authorId: string; | |
| 665 | title: string; | |
| 666 | body: string; | |
| 667 | }): Promise<number | null> { | |
| 668 | try { | |
| 669 | const [inserted] = await db | |
| 670 | .insert(issues) | |
| 671 | .values({ | |
| 672 | repositoryId: args.repositoryId, | |
| 673 | authorId: args.authorId, | |
| 674 | title: args.title, | |
| 675 | body: args.body, | |
| 676 | state: "open", | |
| 677 | }) | |
| 678 | .returning({ id: issues.id, number: issues.number }); | |
| 679 | if (!inserted) return null; | |
| 680 | const labelId = await ensureAdvancementLabel(args.repositoryId); | |
| 681 | if (labelId) { | |
| 682 | await db | |
| 683 | .insert(issueLabels) | |
| 684 | .values({ issueId: inserted.id, labelId }) | |
| 685 | .catch(() => {}); | |
| 686 | } | |
| 687 | return inserted.number ?? null; | |
| 688 | } catch (err) { | |
| 689 | console.warn( | |
| 690 | "[advancement-scanner] issue insert failed:", | |
| 691 | err instanceof Error ? err.message : err | |
| 692 | ); | |
| 693 | return null; | |
| 694 | } | |
| 695 | } | |
| 696 | ||
| 697 | async function defaultRecordAudit( | |
| 698 | finding: AdvancementFinding, | |
| 699 | repositoryId: string | null, | |
| 700 | issueNumber: number | null, | |
| 701 | dedupeKey: string | |
| 702 | ): Promise<void> { | |
| 703 | await audit({ | |
| 704 | repositoryId: repositoryId ?? undefined, | |
| 705 | action: ADVANCEMENT_AUDIT_ACTION, | |
| 706 | targetType: issueNumber !== null ? "issue" : undefined, | |
| 707 | targetId: issueNumber !== null ? String(issueNumber) : undefined, | |
| 708 | metadata: { | |
| 709 | kind: finding.kind, | |
| 710 | title: finding.title, | |
| 711 | urgency: finding.urgency, | |
| 712 | dedupeKey, | |
| 713 | bump: finding.bump ?? null, | |
| 714 | }, | |
| 715 | }); | |
| 716 | } | |
| 717 | ||
| 718 | async function defaultRecordScanComplete( | |
| 719 | result: AdvancementScanResult | |
| 720 | ): Promise<void> { | |
| 721 | await audit({ | |
| 722 | action: ADVANCEMENT_SCAN_COMPLETE_ACTION, | |
| 723 | metadata: { | |
| 724 | totalFindings: result.findings.length, | |
| 725 | openedIssues: result.openedIssues, | |
| 726 | openedPrs: result.openedPrs, | |
| 727 | skippedDedupe: result.skippedDedupe, | |
| 728 | errors: result.errors, | |
| 729 | byKind: countByKind(result.findings), | |
| 730 | }, | |
| 731 | }); | |
| 732 | } | |
| 733 | ||
| 734 | function countByKind( | |
| 735 | findings: AdvancementFinding[] | |
| 736 | ): Record<AdvancementKind, number> { | |
| 737 | const out: Record<AdvancementKind, number> = { | |
| 738 | model_release: 0, | |
| 739 | stack_bump: 0, | |
| 740 | self_improvement: 0, | |
| 741 | trending_feature: 0, | |
| 742 | }; | |
| 743 | for (const f of findings) out[f.kind] += 1; | |
| 744 | return out; | |
| 745 | } | |
| 746 | ||
| 747 | async function defaultProposeBumpPr(args: { | |
| 748 | repositoryId: string; | |
| 749 | dependency: string; | |
| 750 | fromVersion: string; | |
| 751 | toVersion: string; | |
| 752 | baseSha: string; | |
| 753 | }): Promise<{ branch: string; prNumber: number } | null> { | |
| 754 | try { | |
| 755 | return await proposeMajorMigration({ | |
| 756 | repositoryId: args.repositoryId, | |
| 757 | dependency: args.dependency, | |
| 758 | fromVersion: args.fromVersion, | |
| 759 | toVersion: args.toVersion, | |
| 760 | baseSha: args.baseSha, | |
| 761 | }); | |
| 762 | } catch (err) { | |
| 763 | console.warn( | |
| 764 | "[advancement-scanner] proposeBumpPr failed:", | |
| 765 | err instanceof Error ? err.message : err | |
| 766 | ); | |
| 767 | return null; | |
| 768 | } | |
| 769 | } | |
| 770 | ||
| 771 | // --------------------------------------------------------------------------- | |
| 772 | // Telemetry summarisation for the self-improvement probe | |
| 773 | // --------------------------------------------------------------------------- | |
| 774 | ||
| 775 | async function loadSelfImprovementSummary(): Promise<string> { | |
| 776 | const cutoff = new Date( | |
| 777 | Date.now() - ADVANCEMENT_SELF_IMPROVE_LOOKBACK_DAYS * 24 * 60 * 60 * 1000 | |
| 778 | ); | |
| 779 | let auditRows: Array<{ action: string }> = []; | |
| 780 | let deployRows: Array<{ | |
| 781 | status: string; | |
| 782 | durationMs: number | null; | |
| 783 | error: string | null; | |
| 784 | }> = []; | |
| 785 | try { | |
| 786 | auditRows = await db | |
| 787 | .select({ action: auditLog.action }) | |
| 788 | .from(auditLog) | |
| 789 | .where(gte(auditLog.createdAt, cutoff)) | |
| 790 | .orderBy(desc(auditLog.createdAt)) | |
| 791 | .limit(500); | |
| 792 | } catch (err) { | |
| 793 | console.warn( | |
| 794 | "[advancement-scanner] audit summary load failed:", | |
| 795 | err instanceof Error ? err.message : err | |
| 796 | ); | |
| 797 | } | |
| 798 | try { | |
| 799 | deployRows = await db | |
| 800 | .select({ | |
| 801 | status: platformDeploys.status, | |
| 802 | durationMs: platformDeploys.durationMs, | |
| 803 | error: platformDeploys.error, | |
| 804 | }) | |
| 805 | .from(platformDeploys) | |
| 806 | .where(gte(platformDeploys.startedAt, cutoff)) | |
| 807 | .orderBy(desc(platformDeploys.startedAt)) | |
| 808 | .limit(100); | |
| 809 | } catch (err) { | |
| 810 | console.warn( | |
| 811 | "[advancement-scanner] deploy summary load failed:", | |
| 812 | err instanceof Error ? err.message : err | |
| 813 | ); | |
| 814 | } | |
| 815 | const byAction = new Map<string, number>(); | |
| 816 | for (const r of auditRows) { | |
| 817 | byAction.set(r.action, (byAction.get(r.action) || 0) + 1); | |
| 818 | } | |
| 819 | const auditLines = Array.from(byAction.entries()) | |
| 820 | .sort((a, b) => b[1] - a[1]) | |
| 821 | .slice(0, 25) | |
| 822 | .map(([k, v]) => `- ${k}: ${v}`) | |
| 823 | .join("\n"); | |
| 824 | const failed = deployRows.filter((d) => d.status === "failed"); | |
| 825 | const durs = deployRows | |
| 826 | .map((d) => d.durationMs) | |
| 827 | .filter((d): d is number => typeof d === "number"); | |
| 828 | const avgDur = | |
| 829 | durs.length > 0 | |
| 830 | ? Math.round(durs.reduce((a, b) => a + b, 0) / durs.length) | |
| 831 | : null; | |
| 832 | const recentErrors = failed | |
| 833 | .slice(0, 8) | |
| 834 | .map((d) => `- ${d.error?.slice(0, 140) ?? "(no message)"}`) | |
| 835 | .join("\n"); | |
| 836 | return [ | |
| 837 | `## Audit log (${auditRows.length} rows in last ${ADVANCEMENT_SELF_IMPROVE_LOOKBACK_DAYS}d, top actions)`, | |
| 838 | auditLines || "(none)", | |
| 839 | "", | |
| 840 | `## Platform deploys (${deployRows.length} rows; ${failed.length} failed; avg duration ${avgDur ?? "n/a"}ms)`, | |
| 841 | recentErrors || "(no failures)", | |
| 842 | ].join("\n"); | |
| 843 | } | |
| 844 | ||
| 845 | // --------------------------------------------------------------------------- | |
| 846 | // Stack bump probe | |
| 847 | // --------------------------------------------------------------------------- | |
| 848 | ||
| 849 | /** | |
| 850 | * The keystone framework dependencies we care about for "major behind" | |
| 851 | * detection. Everything else is left to the dep-updater watcher. | |
| 852 | */ | |
| 853 | export const STACK_KEYSTONE_DEPS = [ | |
| 854 | "hono", | |
| 855 | "drizzle-orm", | |
| 856 | "drizzle-kit", | |
| 857 | "@anthropic-ai/sdk", | |
| 858 | "postgres", | |
| 859 | "@neondatabase/serverless", | |
| 860 | "marked", | |
| 861 | "highlight.js", | |
| 862 | ]; | |
| 863 | ||
| 864 | async function scanStackBumps( | |
| 865 | loadPackageJson: () => Promise<string | null>, | |
| 866 | fetchLatestVersion: (name: string) => Promise<string | null> | |
| 867 | ): Promise<AdvancementFinding[]> { | |
| 868 | const text = await loadPackageJson(); | |
| 869 | if (!text) return []; | |
| 870 | const manifest = parseManifest(text); | |
| 871 | const all: Record<string, string> = { | |
| 872 | ...manifest.dependencies, | |
| 873 | ...manifest.devDependencies, | |
| 874 | }; | |
| 875 | const findings: AdvancementFinding[] = []; | |
| 876 | for (const dep of STACK_KEYSTONE_DEPS) { | |
| 877 | const range = all[dep]; | |
| 878 | if (!range) continue; | |
| 879 | const latest = await fetchLatestVersion(dep); | |
| 880 | if (!latest) continue; | |
| 881 | const bump = detectMajorBump(range, latest); | |
| 882 | if (!bump) continue; | |
| 883 | findings.push({ | |
| 884 | kind: "stack_bump", | |
| 885 | title: `Bump ${dep} ${bump.from} → ${bump.to}`, | |
| 886 | urgency: "medium", | |
| 887 | suggested_action: `Bump \`${dep}\` from \`${bump.from}\` to \`${bump.to}\` in package.json. Major-version migration assistant should handle the call-site updates.`, | |
| 888 | bump: { dependency: dep, fromVersion: bump.from, toVersion: bump.to }, | |
| 889 | }); | |
| 890 | } | |
| 891 | return findings; | |
| 892 | } | |
| 893 | ||
| 894 | // --------------------------------------------------------------------------- | |
| 895 | // Main entry point | |
| 896 | // --------------------------------------------------------------------------- | |
| 897 | ||
| 898 | /** | |
| 899 | * Run one advancement scan. Always returns a result object — never | |
| 900 | * throws. When AI is unavailable, the model/stack probes still run | |
| 901 | * (they're offline-capable) but the Claude-driven probes return empty | |
| 902 | * findings. | |
| 903 | */ | |
| 904 | export async function runAdvancementScan( | |
| 905 | deps: AdvancementScanDeps = {} | |
| 906 | ): Promise<AdvancementScanResult> { | |
| 907 | const aiAvailable = deps.aiAvailable ?? isAiAvailable; | |
| 908 | const loadPackageJson = deps.loadPackageJson ?? readLocalPackageJson; | |
| 909 | const fetchLatestVersion = deps.fetchLatestVersion ?? fetchNpmLatest; | |
| 910 | const modelCatalogue = deps.modelCatalogue ?? KNOWN_CLAUDE_MODELS; | |
| 911 | const configuredModels = deps.configuredModels ?? defaultConfiguredModels; | |
| 912 | const askSelfImprovement = deps.askSelfImprovement ?? defaultAskSelfImprovement; | |
| 913 | const askTrending = deps.askTrending ?? defaultAskTrending; | |
| 914 | const resolveSelfHostRepo = | |
| 915 | deps.resolveSelfHostRepo ?? defaultResolveSelfHostRepo; | |
| 916 | const isDuplicate = deps.isDuplicate ?? defaultIsDuplicate; | |
| 917 | const openIssue = deps.openIssue ?? defaultOpenIssue; | |
| 918 | const recordAudit = deps.recordAudit ?? defaultRecordAudit; | |
| 919 | const recordScanComplete = | |
| 920 | deps.recordScanComplete ?? defaultRecordScanComplete; | |
| 921 | const proposeBumpPr = deps.proposeBumpPr ?? defaultProposeBumpPr; | |
| 922 | const resolveBaseSha = deps.resolveBaseSha ?? resolveRef; | |
| 923 | const maxFindings = | |
| 924 | deps.maxFindings ?? MAX_ADVANCEMENT_FINDINGS_PER_SCAN; | |
| 925 | ||
| 926 | const result: AdvancementScanResult = { | |
| 927 | findings: [], | |
| 928 | openedIssues: 0, | |
| 929 | openedPrs: 0, | |
| 930 | skippedDedupe: 0, | |
| 931 | errors: 0, | |
| 932 | }; | |
| 933 | ||
| 934 | // 1. Model-release probe (offline, always runs) | |
| 935 | try { | |
| 936 | for (const id of configuredModels()) { | |
| 937 | result.findings.push(...suggestModelUpgrades(id, modelCatalogue)); | |
| 938 | } | |
| 939 | } catch (err) { | |
| 940 | result.errors += 1; | |
| 941 | console.warn("[advancement-scanner] model probe threw:", err); | |
| 942 | } | |
| 943 | ||
| 944 | // 2. Stack bump probe (network — best-effort) | |
| 945 | try { | |
| 946 | const bumps = await scanStackBumps(loadPackageJson, fetchLatestVersion); | |
| 947 | result.findings.push(...bumps); | |
| 948 | } catch (err) { | |
| 949 | result.errors += 1; | |
| 950 | console.warn("[advancement-scanner] stack probe threw:", err); | |
| 951 | } | |
| 952 | ||
| 953 | // 3 + 4 — Claude-driven probes. Only run when AI is wired. | |
| 954 | if (aiAvailable()) { | |
| 955 | try { | |
| 956 | const summary = await loadSelfImprovementSummary(); | |
| 957 | const findings = await askSelfImprovement(summary); | |
| 958 | result.findings.push(...findings); | |
| 959 | } catch (err) { | |
| 960 | result.errors += 1; | |
| 961 | console.warn("[advancement-scanner] self-improvement probe threw:", err); | |
| 962 | } | |
| 963 | try { | |
| 964 | const findings = await askTrending(TRENDING_FEATURE_CATALOGUE); | |
| 965 | result.findings.push(...findings); | |
| 966 | } catch (err) { | |
| 967 | result.errors += 1; | |
| 968 | console.warn("[advancement-scanner] trending probe threw:", err); | |
| 969 | } | |
| 970 | } | |
| 971 | ||
| 972 | // Cap total findings so a hallucinating Claude can't blow the | |
| 973 | // self-host repo's issue feed. | |
| 974 | if (result.findings.length > maxFindings) { | |
| 975 | result.findings = result.findings.slice(0, maxFindings); | |
| 976 | } | |
| 977 | ||
| 978 | // Resolve self-host repo + persist each finding (issue / PR / audit). | |
| 979 | let repo: Awaited<ReturnType<typeof defaultResolveSelfHostRepo>> = null; | |
| 980 | try { | |
| 981 | repo = await resolveSelfHostRepo(); | |
| 982 | } catch (err) { | |
| 983 | result.errors += 1; | |
| 984 | console.warn("[advancement-scanner] resolveSelfHostRepo threw:", err); | |
| 985 | } | |
| 986 | ||
| 987 | // Cache the base sha for stack-bump PR proposals — one lookup per scan. | |
| 988 | let baseSha: string | null = null; | |
| 989 | if (repo) { | |
| 990 | try { | |
| 991 | baseSha = await resolveBaseSha( | |
| 992 | repo.ownerName, | |
| 993 | repo.repoName, | |
| 994 | repo.defaultBranch || "main" | |
| 995 | ); | |
| 996 | } catch (err) { | |
| 997 | console.warn( | |
| 998 | "[advancement-scanner] base sha resolve failed:", | |
| 999 | err instanceof Error ? err.message : err | |
| 1000 | ); | |
| 1001 | } | |
| 1002 | } | |
| 1003 | ||
| 1004 | for (const finding of result.findings) { | |
| 1005 | try { | |
| 1006 | const dedupeKey = advancementDedupeKey(finding.title); | |
| 1007 | // 7-day per-{dep,version} migration throttle preempts any per-finding | |
| 1008 | // dedupe — if the assistant already opened a PR this week, we skip. | |
| 1009 | if ( | |
| 1010 | repo && | |
| 1011 | finding.kind === "stack_bump" && | |
| 1012 | finding.bump && | |
| 1013 | (await recentlyProposed( | |
| 1014 | repo.repositoryId, | |
| 1015 | finding.bump.dependency, | |
| 1016 | finding.bump.toVersion | |
| 1017 | )) | |
| 1018 | ) { | |
| 1019 | result.skippedDedupe += 1; | |
| 1020 | continue; | |
| 1021 | } | |
| 1022 | // Per-finding (issue-body) dedupe — 30-day window. | |
| 1023 | if ( | |
| 1024 | repo && | |
| 1025 | (await isDuplicate(repo.repositoryId, dedupeKey, ADVANCEMENT_DEDUPE_DAYS)) | |
| 1026 | ) { | |
| 1027 | result.skippedDedupe += 1; | |
| 1028 | continue; | |
| 1029 | } | |
| 1030 | ||
| 1031 | // Stack-bump findings with a concrete bump payload get the | |
| 1032 | // migration assistant kickoff. The assistant itself opens the PR. | |
| 1033 | let issueNumber: number | null = null; | |
| 1034 | if ( | |
| 1035 | finding.kind === "stack_bump" && | |
| 1036 | finding.bump && | |
| 1037 | repo && | |
| 1038 | baseSha | |
| 1039 | ) { | |
| 1040 | const prResult = await proposeBumpPr({ | |
| 1041 | repositoryId: repo.repositoryId, | |
| 1042 | dependency: finding.bump.dependency, | |
| 1043 | fromVersion: finding.bump.fromVersion, | |
| 1044 | toVersion: finding.bump.toVersion, | |
| 1045 | baseSha, | |
| 1046 | }); | |
| 1047 | if (prResult) { | |
| 1048 | result.openedPrs += 1; | |
| 1049 | } else if (repo) { | |
| 1050 | // Migration assistant declined (no AI / no usages / etc.) — fall | |
| 1051 | // back to opening an advisory issue so the operator still sees it. | |
| 1052 | const body = renderAdvancementBody(finding, dedupeKey); | |
| 1053 | issueNumber = await openIssue({ | |
| 1054 | repositoryId: repo.repositoryId, | |
| 1055 | authorId: repo.ownerId, | |
| 1056 | title: finding.title.slice(0, 200), | |
| 1057 | body, | |
| 1058 | }); | |
| 1059 | if (issueNumber !== null) result.openedIssues += 1; | |
| 1060 | } | |
| 1061 | } else if (repo) { | |
| 1062 | const body = renderAdvancementBody(finding, dedupeKey); | |
| 1063 | issueNumber = await openIssue({ | |
| 1064 | repositoryId: repo.repositoryId, | |
| 1065 | authorId: repo.ownerId, | |
| 1066 | title: finding.title.slice(0, 200), | |
| 1067 | body, | |
| 1068 | }); | |
| 1069 | if (issueNumber !== null) result.openedIssues += 1; | |
| 1070 | } | |
| 1071 | ||
| 1072 | await recordAudit( | |
| 1073 | finding, | |
| 1074 | repo?.repositoryId ?? null, | |
| 1075 | issueNumber, | |
| 1076 | dedupeKey | |
| 1077 | ); | |
| 1078 | } catch (err) { | |
| 1079 | result.errors += 1; | |
| 1080 | console.warn( | |
| 1081 | `[advancement-scanner] per-finding failure for "${finding.title}":`, | |
| 1082 | err | |
| 1083 | ); | |
| 1084 | } | |
| 1085 | } | |
| 1086 | ||
| 1087 | try { | |
| 1088 | await recordScanComplete(result); | |
| 1089 | } catch (err) { | |
| 1090 | console.warn( | |
| 1091 | "[advancement-scanner] recordScanComplete threw:", | |
| 1092 | err instanceof Error ? err.message : err | |
| 1093 | ); | |
| 1094 | } | |
| 1095 | ||
| 1096 | console.log( | |
| 1097 | `[advancement-scanner] complete findings=${result.findings.length} issues=${result.openedIssues} prs=${result.openedPrs} dedup=${result.skippedDedupe} errors=${result.errors}` | |
| 1098 | ); | |
| 1099 | ||
| 1100 | return result; | |
| 1101 | } | |
| 1102 | ||
| 1103 | // --------------------------------------------------------------------------- | |
| 1104 | // Test-only exports | |
| 1105 | // --------------------------------------------------------------------------- | |
| 1106 | ||
| 1107 | export const __test = { | |
| 1108 | defaultConfiguredModels, | |
| 1109 | defaultAskSelfImprovement, | |
| 1110 | defaultAskTrending, | |
| 1111 | defaultResolveSelfHostRepo, | |
| 1112 | defaultIsDuplicate, | |
| 1113 | defaultOpenIssue, | |
| 1114 | defaultRecordAudit, | |
| 1115 | defaultRecordScanComplete, | |
| 1116 | defaultProposeBumpPr, | |
| 1117 | ensureAdvancementLabel, | |
| 1118 | loadSelfImprovementSummary, | |
| 1119 | scanStackBumps, | |
| 1120 | dedupeMarker, | |
| 1121 | isPlausibleClaudeFinding, | |
| 1122 | countByKind, | |
| 1123 | }; |