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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
spec-ai.ts11.6 KB · 388 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
/**
 * spec-to-PR v2, part 2 — Claude API call + response parser.
 *
 * Given a user spec plus a compact view of the repository (file list +
 * relevant file contents), asks Claude for a minimal set of file edits that
 * implement the spec. The response is parsed, validated, and returned as a
 * discriminated union so the caller (spec-to-PR pipeline) can decide what
 * to do next.
 *
 * Contract:
 *   - Never throws. Every failure path returns `{ok:false, error:string}`.
 *   - Never invents or installs dependencies. Never edits forbidden paths.
 *   - "no edits" is a valid successful result — caller decides policy.
 *
 * Client pattern cribbed from `src/lib/ai-review.ts` (direct `@anthropic-ai/sdk`
 * + per-call `client.messages.create`), kept intentionally consistent with the
 * rest of the `ai-*` modules.
 */

import Anthropic from "@anthropic-ai/sdk";
import { config } from "./config";

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------

export type FileEdit =
  | { action: "create"; path: string; content: string }
  | { action: "edit"; path: string; content: string }
  | { action: "delete"; path: string };

export type SpecAIResult =
  | { ok: true; edits: FileEdit[]; summary: string }
  | { ok: false; error: string };

export interface GenerateSpecEditsArgs {
  spec: string;
  fileList: string[];
  relevantFiles: Array<{ path: string; content: string }>;
  defaultBranch: string;
  /** Model override. Default: `claude-sonnet-4-6` as specified by the caller. */
  model?: string;
}

// ---------------------------------------------------------------------------
// Tunables
// ---------------------------------------------------------------------------

/** Total prompt size cap, in bytes. Matches the v2 spec. */
const MAX_PROMPT_BYTES = 50_000;
/** Hard cap on file list lines. */
const MAX_FILE_LIST_LINES = 500;
/** Default model — spec says `claude-sonnet-4-6`. */
const DEFAULT_MODEL = "claude-sonnet-4-6";

/**
 * Paths we will never let Claude edit, regardless of what it returns.
 * Matches substrings/prefixes — see `isForbiddenPath`.
 */
const FORBIDDEN_PATTERNS: Array<string | RegExp> = [
  "BUILD_BIBLE.md",
  "src/views/layout.tsx",
  /^drizzle\//,
  /^legal\//,
  "LICENSE",
  /^\.github\//,
];

// ---------------------------------------------------------------------------
// Public helpers (exported so tests can poke at the pure bits)
// ---------------------------------------------------------------------------

/**
 * True if `path` targets a protected area of the tree that Claude must not
 * touch. Rejects edits as a defence-in-depth check in addition to the
 * instruction in the system prompt.
 */
export function isForbiddenPath(path: string): boolean {
  if (!path) return true;
  for (const pat of FORBIDDEN_PATTERNS) {
    if (typeof pat === "string") {
      if (path === pat) return true;
    } else if (pat.test(path)) {
      return true;
    }
  }
  return false;
}

/**
 * True if `path` is a safe, relative, non-traversing filesystem path.
 * Rejects absolute paths, `..` traversal, backslashes, and empty strings.
 */
export function isSafeRelativePath(path: string): boolean {
  if (typeof path !== "string") return false;
  if (!path) return false;
  if (path.startsWith("/")) return false;
  if (path.includes("\\")) return false;
  const parts = path.split("/");
  for (const part of parts) {
    if (part === "" || part === "." || part === "..") return false;
  }
  return true;
}

/**
 * Structural + policy validation for a single edit. Returns true only if:
 *   - `action` is one of create / edit / delete
 *   - `path` is a safe relative path and not forbidden
 *   - `content` is a string for create / edit
 */
export function validateEdit(edit: unknown): edit is FileEdit {
  if (!edit || typeof edit !== "object") return false;
  const e = edit as Record<string, unknown>;
  const action = e.action;
  const path = e.path;
  if (typeof path !== "string") return false;
  if (!isSafeRelativePath(path)) return false;
  if (isForbiddenPath(path)) return false;
  if (action === "create" || action === "edit") {
    return typeof e.content === "string";
  }
  if (action === "delete") {
    return true;
  }
  return false;
}

/**
 * Parse a Claude response body (which may be wrapped in ```json / ``` fences or
 * contain surrounding prose) into a JSON object.
 *
 * Returns `null` on any parse failure.
 */
export function parseAiJsonResponse(text: string): unknown | null {
  if (typeof text !== "string" || !text) return null;
  let trimmed = text.trim();

  // Strip leading / trailing triple-backtick fences, optionally tagged "json".
  const fenceMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
  if (fenceMatch) {
    trimmed = fenceMatch[1].trim();
  }

  try {
    return JSON.parse(trimmed);
  } catch {
    // Fall back to first balanced {...} block if any.
    const braceMatch = trimmed.match(/\{[\s\S]*\}/);
    if (braceMatch) {
      try {
        return JSON.parse(braceMatch[0]);
      } catch {
        return null;
      }
    }
    return null;
  }
}

/**
 * Build the system prompt. Kept as an exported helper so it can be inspected
 * from tests without calling Claude.
 */
export function buildSystemPrompt(): string {
  return [
    "You are an AI coding assistant working inside a repo.",
    "Given a user spec and the repo's file tree + relevant files, produce file edits that implement the spec.",
    "",
    "Rules:",
    "- Minimal scope — implement one feature at a time.",
    "- Never edit tests, CI configs, BUILD_BIBLE.md, locked files, or license files.",
    "- Never invent new dependencies.",
    "- Keep edits surgical and focused on the spec.",
    "",
    "Respond with ONLY a JSON object matching this TypeScript type, with no prose, no markdown fences, no commentary:",
    "",
    "{",
    '  summary: string,',
    "  edits: Array<{ action: 'create' | 'edit' | 'delete', path: string, content?: string }>",
    "}",
    "",
    "For create and edit actions, `content` is required and must be the full file contents.",
    "For delete actions, omit `content`.",
    "Paths must be relative (no leading /, no ..).",
  ].join("\n");
}

/**
 * Build the user prompt, fitting inside `MAX_PROMPT_BYTES`.
 *
 * Truncation strategy, in order:
 *   1. Cap file list at `MAX_FILE_LIST_LINES`.
 *   2. If still over budget, drop trailing file-list entries.
 *   3. If still over, drop lowest-ranked (last) relevant files.
 *
 * `relevantFiles` is assumed to be pre-sorted by the caller in descending
 * score order (most relevant first). We only ever drop from the tail.
 */
export function buildUserPrompt(args: GenerateSpecEditsArgs): string {
  const { spec, defaultBranch } = args;
  let fileList = args.fileList.slice(0, MAX_FILE_LIST_LINES);
  const relevant = args.relevantFiles.slice();

  const header = () =>
    [
      `Default branch: ${defaultBranch}`,
      "",
      "User spec:",
      spec,
      "",
    ].join("\n");

  const render = (): string => {
    const parts: string[] = [header()];
    parts.push("Repository file list:");
    parts.push("```");
    parts.push(fileList.join("\n"));
    parts.push("```");
    parts.push("");
    if (relevant.length > 0) {
      parts.push("Relevant files:");
      parts.push("");
      for (const f of relevant) {
        parts.push("```" + (f.path || ""));
        parts.push(f.content || "");
        parts.push("```");
        parts.push("");
      }
    }
    return parts.join("\n");
  };

  let out = render();
  if (byteLen(out) <= MAX_PROMPT_BYTES) return out;

  // 1. Trim file list.
  while (fileList.length > 0 && byteLen(render()) > MAX_PROMPT_BYTES) {
    fileList = fileList.slice(0, Math.max(0, fileList.length - 10));
  }
  out = render();
  if (byteLen(out) <= MAX_PROMPT_BYTES) return out;

  // 2. Drop lowest-scoring (tail) relevant files one at a time.
  while (relevant.length > 0 && byteLen(render()) > MAX_PROMPT_BYTES) {
    relevant.pop();
  }

  return render();
}

function byteLen(s: string): number {
  // Bun / Node both expose Buffer; fall back to a UTF-8 estimate otherwise.
  try {
    return Buffer.byteLength(s, "utf8");
  } catch {
    return s.length;
  }
}

// ---------------------------------------------------------------------------
// Anthropic client (local to this module — matches ai-review.ts pattern)
// ---------------------------------------------------------------------------

let _client: Anthropic | null = null;

function getClient(): Anthropic {
  if (!_client) {
    _client = new Anthropic({ apiKey: config.anthropicApiKey });
  }
  return _client;
}

/**
 * Drop the cached Anthropic client. Only intended for tests that need to
 * swap `globalThis.fetch` between calls — the SDK captures `fetch` at
 * client construction time, so reusing a client would pin the stubbed
 * fetch from an earlier test.
 *
 * @internal
 */
export function _resetClientForTests(): void {
  _client = null;
}

// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------

/**
 * Ask Claude to propose file edits that implement `spec`.
 *
 * Never throws — returns a discriminated union. On validation failure a
 * proposed edit is silently dropped; if *every* proposed edit is rejected
 * the result is still `{ok:true, edits:[], summary:"..."}` so the caller
 * can distinguish "AI produced nothing usable" from "AI / transport error".
 */
export async function generateSpecEdits(
  args: GenerateSpecEditsArgs
): Promise<SpecAIResult> {
  if (!config.anthropicApiKey) {
    return { ok: false, error: "ANTHROPIC_API_KEY required" };
  }

  const model = args.model || DEFAULT_MODEL;

  let systemPrompt: string;
  let userPrompt: string;
  try {
    systemPrompt = buildSystemPrompt();
    userPrompt = buildUserPrompt(args);
  } catch (err) {
    return {
      ok: false,
      error: `prompt construction failed: ${errMessage(err)}`,
    };
  }

  let rawText: string;
  try {
    const client = getClient();
    const message = await client.messages.create({
      model,
      max_tokens: 4096,
      temperature: 0.2,
      system: systemPrompt,
      messages: [{ role: "user", content: userPrompt }],
    });
    rawText = "";
    for (const block of message.content) {
      if (block.type === "text") {
        rawText += block.text;
      }
    }
  } catch (err) {
    return { ok: false, error: `AI call failed: ${errMessage(err)}` };
  }

  const parsed = parseAiJsonResponse(rawText);
  if (!parsed || typeof parsed !== "object") {
    return { ok: false, error: "AI returned invalid JSON" };
  }

  const obj = parsed as Record<string, unknown>;
  const summaryRaw = obj.summary;
  const editsRaw = obj.edits;
  const summary =
    typeof summaryRaw === "string" && summaryRaw.trim()
      ? summaryRaw.trim()
      : "";

  if (!Array.isArray(editsRaw)) {
    return { ok: false, error: "AI returned invalid JSON" };
  }

  const edits: FileEdit[] = [];
  for (const candidate of editsRaw) {
    if (validateEdit(candidate)) {
      edits.push(candidate);
    }
    // Forbidden / malformed edits are silently dropped. The caller can look
    // at `edits.length` vs the original `editsRaw.length` if it cares.
  }

  if (edits.length === 0) {
    return {
      ok: true,
      edits: [],
      summary: summary || "AI proposed no changes",
    };
  }

  return {
    ok: true,
    edits,
    summary: summary || "AI proposed changes",
  };
}

function errMessage(err: unknown): string {
  if (err instanceof Error) return err.message;
  if (typeof err === "string") return err;
  try {
    return JSON.stringify(err);
  } catch {
    return "unknown error";
  }
}