Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

advancement-scanner.tsBlame1123 lines · 1 contributor
d199847Claude1/**
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
40import { createHash } from "crypto";
41import { readFile } from "fs/promises";
42import { join } from "path";
43import { and, desc, eq, gte, like } from "drizzle-orm";
44import { db } from "../db";
45import {
46 auditLog,
47 issueLabels,
48 issues,
49 labels,
50 repositories,
51 users,
52} from "../db/schema";
53import { platformDeploys } from "../db/schema-deploys";
54import {
55 MODEL_HAIKU,
56 MODEL_SONNET,
57 extractText,
58 getAnthropic,
59 isAiAvailable,
60 parseJsonResponse,
61} from "./ai-client";
62import { audit } from "./notify";
63import { parseManifest } from "./dep-updater";
64import {
65 proposeMajorMigration,
66 recentlyProposed,
67 detectMajorBump,
68} from "./migration-assistant";
69import { resolveRef } from "../git/repository";
70
71// ---------------------------------------------------------------------------
72// Constants
73// ---------------------------------------------------------------------------
74
75export const ADVANCEMENT_AUDIT_ACTION = "ai.advancement.finding";
76export const ADVANCEMENT_SCAN_COMPLETE_ACTION = "ai.advancement.scan_complete";
77export const ADVANCEMENT_LABEL_NAME = "ai:advancement";
78export const ADVANCEMENT_DEDUPE_MARKER_PREFIX =
79 "<!-- gluecron:advancement:dedupe=";
80export const ADVANCEMENT_DEDUPE_MARKER_SUFFIX = " -->";
81
82/** Dedupe horizon — same title (sha256) won't be re-proposed for 30 days. */
83export const ADVANCEMENT_DEDUPE_DAYS = 30;
84
85/** Hard cap on findings persisted per scan (runaway protection). */
86export const MAX_ADVANCEMENT_FINDINGS_PER_SCAN = 12;
87
88/** Default self-host repo when SELF_HOST_REPO is not configured. */
89export const ADVANCEMENT_DEFAULT_SELF_HOST_REPO = "ccantynz-alt/Gluecron.com";
90
91/** Lookback for the self-improvement signal — last 7 days. */
92export const ADVANCEMENT_SELF_IMPROVE_LOOKBACK_DAYS = 7;
93
94// ---------------------------------------------------------------------------
95// Public types
96// ---------------------------------------------------------------------------
97
98export type AdvancementKind =
99 | "model_release"
100 | "stack_bump"
101 | "self_improvement"
102 | "trending_feature";
103
104export type AdvancementUrgency = "low" | "medium" | "high";
105
106export 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
121export 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 */
147export 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 */
165export 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 */
183export 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. */
199export function advancementDedupeKey(title: string): string {
200 return createHash("sha256")
201 .update(title.trim().toLowerCase())
202 .digest("hex")
203 .slice(0, 32);
204}
205
206function 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 */
214export 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 */
258export 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. */
329export 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 */
342export 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
367export 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
441function defaultConfiguredModels(): string[] {
442 return [MODEL_SONNET, MODEL_HAIKU];
443}
444
445async 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
458Respond 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
469Limit to the top 3 highest-leverage improvements. Return {"findings": []} if nothing stands out — silence is correct for a healthy platform.
470
471Telemetry 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
493async 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
509Respond 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
520Return {"findings": []} if none of them are a good fit.
521
522Catalogue:
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. */
544function 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
559async 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
599async 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
628async 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
662async 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
697async 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
718async 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
734function 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
747async 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
775async 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 */
853export 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
864async 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 */
904export 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
1107export 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};