igel-qe-core 1.0.16 → 1.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { Command } from "commander";
3
3
  import chalk from "chalk";
4
4
  import { spawn, spawnSync, execSync } from "child_process";
5
- import { existsSync, mkdirSync, writeFileSync, readdirSync, statSync } from "fs";
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from "fs";
6
6
  import { join, dirname } from "path";
7
7
  import { fileURLToPath } from "url";
8
8
  import { setupPlatform } from "./platform.js";
@@ -220,34 +220,63 @@ function listFrameworkContextFiles(workspaceRoot) {
220
220
  }
221
221
  return Array.from(new Set(found)).sort();
222
222
  }
223
- function isCopilotCliAvailable() {
224
- // Use a short timeout so this never blocks if copilot hangs on startup.
225
- const check = spawnSync("copilot", ["--version"], {
223
+ function detectCopilotVariant() {
224
+ // Try `gh copilot --version` first (modern, official gh extension)
225
+ const ghCheck = spawnSync("gh", ["copilot", "--version"], {
226
226
  encoding: "utf-8",
227
227
  shell: process.platform === "win32",
228
- timeout: 10_000,
228
+ timeout: 8_000,
229
229
  });
230
- return check.status === 0 && !check.error;
230
+ if (ghCheck.status === 0 && !ghCheck.error)
231
+ return "gh";
232
+ // Fallback: legacy standalone `copilot` binary
233
+ const legacyCheck = spawnSync("copilot", ["--version"], {
234
+ encoding: "utf-8",
235
+ shell: process.platform === "win32",
236
+ timeout: 8_000,
237
+ });
238
+ if (legacyCheck.status === 0 && !legacyCheck.error)
239
+ return "legacy";
240
+ return null;
241
+ }
242
+ function isCopilotCliAvailable() {
243
+ return detectCopilotVariant() !== null;
231
244
  }
232
245
  function isCopilotAuthenticated() {
233
- // Use a lightweight auth-status check instead of spawning a full agent session.
234
- // "copilot auth status" exits 0 when authenticated, non-zero otherwise.
235
- // Fallback: if the sub-command doesn't exist, treat as unauthenticated.
246
+ const variant = detectCopilotVariant();
247
+ if (!variant)
248
+ return false;
249
+ if (variant === "gh") {
250
+ // `gh auth status` exits 0 when authenticated
251
+ const probe = spawnSync("gh", ["auth", "status"], {
252
+ encoding: "utf-8",
253
+ shell: process.platform === "win32",
254
+ timeout: 10_000,
255
+ });
256
+ return probe.status === 0 && !probe.error;
257
+ }
258
+ // Legacy variant — try `copilot auth status`
236
259
  const probe = spawnSync("copilot", ["auth", "status"], {
237
260
  encoding: "utf-8",
238
261
  shell: process.platform === "win32",
239
- timeout: 10_000, // 10 s max — simple status call should be instant
262
+ timeout: 10_000,
240
263
  });
241
264
  return probe.status === 0 && !probe.error;
242
265
  }
243
266
  function ensureCopilotLogin(workspaceRoot) {
244
- if (!isCopilotCliAvailable())
267
+ const variant = detectCopilotVariant();
268
+ if (!variant)
245
269
  return false;
246
270
  if (isCopilotAuthenticated())
247
271
  return true;
248
272
  console.log(chalk.yellow(" ⚠ GitHub Copilot CLI is not authenticated."));
249
273
  console.log(chalk.yellow(" → Please complete Copilot login to continue context enrichment."));
250
- const login = spawnSync("copilot", ["login"], { stdio: "inherit", encoding: "utf-8", shell: process.platform === "win32" });
274
+ const loginCmd = variant === "gh" ? ["gh", "auth", "login"] : ["copilot", "login"];
275
+ const login = spawnSync(loginCmd[0], loginCmd.slice(1), {
276
+ stdio: "inherit",
277
+ encoding: "utf-8",
278
+ shell: process.platform === "win32",
279
+ });
251
280
  if (login.status !== 0)
252
281
  return false;
253
282
  return isCopilotAuthenticated();
@@ -255,6 +284,59 @@ function ensureCopilotLogin(workspaceRoot) {
255
284
  // Maximum number of context.md files to enrich per init run.
256
285
  // Prevents extremely long waits on repos with many folders.
257
286
  const MAX_ENRICHMENT_FILES = 15;
287
+ /**
288
+ * Attempt to enrich a single context.md file using `gh copilot suggest`.
289
+ * Returns the enriched text, or null on failure/empty response.
290
+ */
291
+ function tryGhCopilotSuggest(workspaceRoot, relPath, currentContent) {
292
+ const prompt = [
293
+ `You are enriching a context.md file for an automation test framework.`,
294
+ `The file is located at: ${relPath} (relative to the workspace root).`,
295
+ ``,
296
+ `Current content:`,
297
+ `\`\`\`markdown`,
298
+ currentContent,
299
+ `\`\`\``,
300
+ ``,
301
+ `Rewrite the content above to be concrete, human-understandable, and LLM-ready.`,
302
+ `Requirements:`,
303
+ `- Keep the same heading structure (# Context, ## Purpose, ## Key Assets, ## Conventions, ## Risks and Dependencies).`,
304
+ `- Replace placeholder text with specific details inferred from the folder path and framework context.`,
305
+ `- Be concise but informative. Each section: 2–5 bullet points or short sentences.`,
306
+ `- Output ONLY the improved markdown. Do NOT add commentary, code fences, or extra text around it.`,
307
+ ].join("\n");
308
+ const variant = detectCopilotVariant();
309
+ let result;
310
+ if (variant === "gh") {
311
+ // Modern: `gh copilot suggest -t shell "<prompt>"`
312
+ // We use --no-color so output can be parsed cleanly.
313
+ result = spawnSync("gh", ["copilot", "suggest", "-t", "shell", "--", prompt], {
314
+ cwd: workspaceRoot,
315
+ encoding: "utf-8",
316
+ shell: process.platform === "win32",
317
+ timeout: 120_000,
318
+ input: "\n", // auto-accept any interactive prompt
319
+ maxBuffer: 4 * 1024 * 1024,
320
+ });
321
+ }
322
+ else if (variant === "legacy") {
323
+ // Legacy @githubnext/github-copilot-cli binary
324
+ result = spawnSync("copilot", ["-C", workspaceRoot, "-p", prompt, "--no-color", "-s"], {
325
+ encoding: "utf-8",
326
+ shell: process.platform === "win32",
327
+ timeout: 120_000,
328
+ input: "",
329
+ maxBuffer: 4 * 1024 * 1024,
330
+ });
331
+ }
332
+ else {
333
+ return null;
334
+ }
335
+ if (!result || result.status !== 0 || result.error)
336
+ return null;
337
+ const text = (result.stdout || "").trim();
338
+ return text.length > 50 ? text : null;
339
+ }
258
340
  function enrichWithCopilotCli(workspaceRoot, contextFiles) {
259
341
  let updated = 0;
260
342
  let failed = 0;
@@ -263,38 +345,37 @@ function enrichWithCopilotCli(workspaceRoot, contextFiles) {
263
345
  const total = filesToProcess.length;
264
346
  for (let i = 0; i < filesToProcess.length; i++) {
265
347
  const absPath = filesToProcess[i];
266
- const relPath = absPath.replace(`${workspaceRoot}/`, "").replace(`${workspaceRoot}\\`, "");
267
- // Show per-file progress so the user knows work is happening.
348
+ // Normalise to forward slashes so the prompt is readable on all platforms.
349
+ const relPath = absPath
350
+ .replace(`${workspaceRoot}\\`, "")
351
+ .replace(`${workspaceRoot}/`, "")
352
+ .replace(/\\/g, "/");
268
353
  process.stdout.write(` [${i + 1}/${total}] Enriching ${relPath}…`);
269
- const prompt = [
270
- `Update the markdown file at path '${relPath}' in place.`,
271
- "Goal: make it both human-understandable and LLM-ready for automation framework reasoning.",
272
- "Requirements:",
273
- "- Keep heading style consistent.",
274
- "- Include: purpose, architecture role, key assets, dependencies, conventions, risks, and update guidance.",
275
- "- Be concrete and repository-aware.",
276
- "- Do not modify any file other than the target file.",
277
- "- Preserve valid markdown.",
278
- ].join("\n");
279
- const result = spawnSync("copilot", [
280
- "-C", workspaceRoot,
281
- "-p", prompt,
282
- "--allow-all-tools",
283
- "--allow-all-paths",
284
- "--no-color",
285
- "-s",
286
- ], {
287
- encoding: "utf-8",
288
- shell: process.platform === "win32",
289
- timeout: 120_000, // 2 min per file — prevents indefinite hangs
290
- input: "", // close stdin immediately
291
- });
292
- if (result.status === 0 && !result.error) {
293
- process.stdout.write(" ✓\n");
294
- updated += 1;
354
+ let currentContent = "";
355
+ try {
356
+ currentContent = readFileSync(absPath, "utf-8");
357
+ }
358
+ catch {
359
+ process.stdout.write(" (unreadable)\n");
360
+ failed += 1;
361
+ continue;
362
+ }
363
+ const enriched = tryGhCopilotSuggest(workspaceRoot, relPath, currentContent);
364
+ if (enriched) {
365
+ try {
366
+ writeFileSync(absPath, enriched, "utf-8");
367
+ process.stdout.write(" ✓\n");
368
+ updated += 1;
369
+ }
370
+ catch {
371
+ process.stdout.write(" ✗ (write failed)\n");
372
+ failed += 1;
373
+ }
295
374
  }
296
375
  else {
297
- process.stdout.write(" ✗\n");
376
+ // Copilot returned empty/trivial output — not a hard failure.
377
+ // The Python AST enricher will handle these in the fallback pass.
378
+ process.stdout.write(" ✗ (empty response)\n");
298
379
  failed += 1;
299
380
  }
300
381
  }
@@ -463,21 +544,71 @@ program
463
544
  if (allContextFiles.length === 0) {
464
545
  console.log(chalk.yellow(" ⚠ No context.md files found for Copilot enrichment"));
465
546
  }
466
- else if (!isCopilotCliAvailable()) {
467
- console.log(chalk.yellow(" ⚠ Copilot CLI not installed; skipping Copilot enrichment"));
468
- }
469
- else if (!ensureCopilotLogin(workspaceRoot)) {
470
- console.log(chalk.yellow(" ⚠ Copilot login was not completed; skipping Copilot enrichment"));
471
- }
472
547
  else {
473
548
  const cap = Math.min(allContextFiles.length, MAX_ENRICHMENT_FILES);
474
549
  if (allContextFiles.length > MAX_ENRICHMENT_FILES) {
475
550
  console.log(chalk.gray(` - ${allContextFiles.length} context files found; enriching first ${MAX_ENRICHMENT_FILES} (re-run to continue)`));
476
551
  }
477
- const run = enrichWithCopilotCli(workspaceRoot, allContextFiles);
478
- console.log(chalk.green(` ✓ Copilot updated ${run.updated}/${cap} context.md file(s)`));
479
- if (run.failed > 0) {
480
- console.log(chalk.yellow(` ⚠ Copilot failed to update ${run.failed} context.md file(s)`));
552
+ const copilotVariant = detectCopilotVariant();
553
+ let copilotUpdated = 0;
554
+ let copilotFailed = 0;
555
+ if (!copilotVariant) {
556
+ console.log(chalk.gray(" - GitHub Copilot CLI not found; using Python AST enricher directly"));
557
+ }
558
+ else if (!ensureCopilotLogin(workspaceRoot)) {
559
+ console.log(chalk.yellow(" ⚠ Copilot login was not completed; using Python AST enricher as fallback"));
560
+ }
561
+ else {
562
+ // Primary strategy: Copilot CLI
563
+ const run = enrichWithCopilotCli(workspaceRoot, allContextFiles);
564
+ copilotUpdated = run.updated;
565
+ copilotFailed = run.failed;
566
+ if (copilotUpdated > 0) {
567
+ console.log(chalk.green(` ✓ Copilot updated ${copilotUpdated}/${cap} context.md file(s)`));
568
+ }
569
+ }
570
+ // Fallback / supplementary: Python AST enricher.
571
+ // Runs when Copilot is unavailable OR Copilot returned empty responses.
572
+ // The Python enricher does not require Copilot — it uses AST analysis
573
+ // and the Azure OpenAI key already configured in the project.
574
+ const needsFallback = copilotFailed > 0 || copilotUpdated === 0;
575
+ if (needsFallback) {
576
+ const skippedFiles = allContextFiles
577
+ .slice(0, MAX_ENRICHMENT_FILES)
578
+ .filter((f) => {
579
+ // Only send files that Copilot did NOT update (i.e. the ones that failed).
580
+ // We approximate this as: all files when copilotUpdated=0,
581
+ // or the trailing copilotFailed files otherwise.
582
+ return true; // enrich_framework_context is idempotent — safe to re-enrich
583
+ });
584
+ if (skippedFiles.length > 0) {
585
+ if (copilotVariant) {
586
+ console.log(chalk.gray(` - Falling back to Python AST enricher for ${copilotFailed} file(s) Copilot missed…`));
587
+ }
588
+ else {
589
+ console.log(chalk.gray(` - Enriching ${Math.min(skippedFiles.length, MAX_ENRICHMENT_FILES)} context.md file(s) via Python AST enricher…`));
590
+ }
591
+ try {
592
+ const enrichResult = await runWorkflow("enrich_framework_context", {
593
+ repo_path: workspaceRoot,
594
+ context_paths: skippedFiles.slice(0, MAX_ENRICHMENT_FILES),
595
+ });
596
+ const pyUpdated = Number(enrichResult.updated ?? 0);
597
+ const pyFailed = Number(enrichResult.failed ?? 0);
598
+ if (pyUpdated > 0) {
599
+ console.log(chalk.green(` ✓ Python AST enricher updated ${pyUpdated} context.md file(s)`));
600
+ }
601
+ if (pyFailed > 0) {
602
+ console.log(chalk.yellow(` ⚠ Python AST enricher could not update ${pyFailed} file(s) (DB may be offline — files are still readable)`));
603
+ }
604
+ }
605
+ catch (pyErr) {
606
+ // Python enricher failed (likely DB offline) — not fatal.
607
+ // Context files still have the scaffolded template content.
608
+ console.log(chalk.yellow(` ⚠ Python enricher skipped: ${pyErr instanceof Error ? pyErr.message : String(pyErr)}`));
609
+ console.log(chalk.gray(" - Context files contain template scaffolding; enrichment can be re-run after DB is configured."));
610
+ }
611
+ }
481
612
  }
482
613
  }
483
614
  }
@@ -660,6 +791,22 @@ program
660
791
  console.log(JSON.stringify(result, null, 2));
661
792
  }
662
793
  else {
794
+ // DB-offline / degraded state — return a helpful message rather than nothing
795
+ if (result.db_offline || result.error) {
796
+ const msg = String(result.message || result.error || "Unknown error");
797
+ if (result.db_offline) {
798
+ console.log(chalk.yellow("\n⚠ Database not reachable"));
799
+ console.log(chalk.gray(` ${msg}`));
800
+ console.log(chalk.gray("\n Checklist:"));
801
+ console.log(chalk.gray(" 1. Ensure PostgreSQL is running on 127.0.0.1:5432"));
802
+ console.log(chalk.gray(" 2. Set DB_PASSWORD (and optionally DB_USER, DB_NAME) in your .env or shell"));
803
+ console.log(chalk.gray(" 3. Re-run: igel-qe init"));
804
+ }
805
+ else {
806
+ console.log(chalk.yellow(`\n⚠ Status unavailable: ${msg}`));
807
+ }
808
+ return;
809
+ }
663
810
  const state = result;
664
811
  console.log(chalk.bold("\nProject State"));
665
812
  const ps = state.project_state;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "igel-qe-core",
3
- "version": "1.0.16",
3
+ "version": "1.0.20",
4
4
  "description": "IGEL QE Developer Experience Layer (CLI & MCP)",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/src/cli/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { Command } from "commander";
4
4
  import chalk from "chalk";
5
5
  import { spawn, spawnSync, execSync } from "child_process";
6
- import { existsSync, mkdirSync, writeFileSync, readdirSync, statSync } from "fs";
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from "fs";
7
7
  import { join, dirname } from "path";
8
8
  import { fileURLToPath } from "url";
9
9
  import { setupPlatform } from "./platform.js";
@@ -231,40 +231,75 @@ function listFrameworkContextFiles(workspaceRoot: string): string[] {
231
231
  return Array.from(new Set(found)).sort();
232
232
  }
233
233
 
234
- function isCopilotCliAvailable(): boolean {
235
- // Use a short timeout so this never blocks if copilot hangs on startup.
236
- const check = spawnSync("copilot", ["--version"], {
234
+ // ── Copilot CLI detection ──────────────────────────────────────────────────────
235
+ // Supports two CLI variants:
236
+ // 1. Modern: `gh copilot suggest "<prompt>"` (gh extension, current standard)
237
+ // 2. Legacy: `copilot --version` (@githubnext/github-copilot-cli, deprecated)
238
+ // Detection tries `gh copilot` first; falls back to bare `copilot` binary.
239
+
240
+ type CopilotVariant = "gh" | "legacy" | null;
241
+
242
+ function detectCopilotVariant(): CopilotVariant {
243
+ // Try `gh copilot --version` first (modern, official gh extension)
244
+ const ghCheck = spawnSync("gh", ["copilot", "--version"], {
237
245
  encoding: "utf-8",
238
246
  shell: process.platform === "win32",
239
- timeout: 10_000,
247
+ timeout: 8_000,
240
248
  });
241
- return check.status === 0 && !check.error;
249
+ if (ghCheck.status === 0 && !ghCheck.error) return "gh";
250
+
251
+ // Fallback: legacy standalone `copilot` binary
252
+ const legacyCheck = spawnSync("copilot", ["--version"], {
253
+ encoding: "utf-8",
254
+ shell: process.platform === "win32",
255
+ timeout: 8_000,
256
+ });
257
+ if (legacyCheck.status === 0 && !legacyCheck.error) return "legacy";
258
+
259
+ return null;
260
+ }
261
+
262
+ function isCopilotCliAvailable(): boolean {
263
+ return detectCopilotVariant() !== null;
242
264
  }
243
265
 
244
266
  function isCopilotAuthenticated(): boolean {
245
- // Use a lightweight auth-status check instead of spawning a full agent session.
246
- // "copilot auth status" exits 0 when authenticated, non-zero otherwise.
247
- // Fallback: if the sub-command doesn't exist, treat as unauthenticated.
248
- const probe = spawnSync(
249
- "copilot",
250
- ["auth", "status"],
251
- {
267
+ const variant = detectCopilotVariant();
268
+ if (!variant) return false;
269
+
270
+ if (variant === "gh") {
271
+ // `gh auth status` exits 0 when authenticated
272
+ const probe = spawnSync("gh", ["auth", "status"], {
252
273
  encoding: "utf-8",
253
274
  shell: process.platform === "win32",
254
- timeout: 10_000, // 10 s max — simple status call should be instant
255
- },
256
- );
275
+ timeout: 10_000,
276
+ });
277
+ return probe.status === 0 && !probe.error;
278
+ }
279
+
280
+ // Legacy variant — try `copilot auth status`
281
+ const probe = spawnSync("copilot", ["auth", "status"], {
282
+ encoding: "utf-8",
283
+ shell: process.platform === "win32",
284
+ timeout: 10_000,
285
+ });
257
286
  return probe.status === 0 && !probe.error;
258
287
  }
259
288
 
260
289
  function ensureCopilotLogin(workspaceRoot: string): boolean {
261
- if (!isCopilotCliAvailable()) return false;
290
+ const variant = detectCopilotVariant();
291
+ if (!variant) return false;
262
292
  if (isCopilotAuthenticated()) return true;
263
293
 
264
294
  console.log(chalk.yellow(" ⚠ GitHub Copilot CLI is not authenticated."));
265
295
  console.log(chalk.yellow(" → Please complete Copilot login to continue context enrichment."));
266
296
 
267
- const login = spawnSync("copilot", ["login"], { stdio: "inherit", encoding: "utf-8", shell: process.platform === "win32" });
297
+ const loginCmd = variant === "gh" ? ["gh", "auth", "login"] : ["copilot", "login"];
298
+ const login = spawnSync(loginCmd[0], loginCmd.slice(1), {
299
+ stdio: "inherit",
300
+ encoding: "utf-8",
301
+ shell: process.platform === "win32",
302
+ });
268
303
  if (login.status !== 0) return false;
269
304
  return isCopilotAuthenticated();
270
305
  }
@@ -273,6 +308,72 @@ function ensureCopilotLogin(workspaceRoot: string): boolean {
273
308
  // Prevents extremely long waits on repos with many folders.
274
309
  const MAX_ENRICHMENT_FILES = 15;
275
310
 
311
+ /**
312
+ * Attempt to enrich a single context.md file using `gh copilot suggest`.
313
+ * Returns the enriched text, or null on failure/empty response.
314
+ */
315
+ function tryGhCopilotSuggest(
316
+ workspaceRoot: string,
317
+ relPath: string,
318
+ currentContent: string,
319
+ ): string | null {
320
+ const prompt = [
321
+ `You are enriching a context.md file for an automation test framework.`,
322
+ `The file is located at: ${relPath} (relative to the workspace root).`,
323
+ ``,
324
+ `Current content:`,
325
+ `\`\`\`markdown`,
326
+ currentContent,
327
+ `\`\`\``,
328
+ ``,
329
+ `Rewrite the content above to be concrete, human-understandable, and LLM-ready.`,
330
+ `Requirements:`,
331
+ `- Keep the same heading structure (# Context, ## Purpose, ## Key Assets, ## Conventions, ## Risks and Dependencies).`,
332
+ `- Replace placeholder text with specific details inferred from the folder path and framework context.`,
333
+ `- Be concise but informative. Each section: 2–5 bullet points or short sentences.`,
334
+ `- Output ONLY the improved markdown. Do NOT add commentary, code fences, or extra text around it.`,
335
+ ].join("\n");
336
+
337
+ const variant = detectCopilotVariant();
338
+
339
+ let result;
340
+ if (variant === "gh") {
341
+ // Modern: `gh copilot suggest -t shell "<prompt>"`
342
+ // We use --no-color so output can be parsed cleanly.
343
+ result = spawnSync(
344
+ "gh",
345
+ ["copilot", "suggest", "-t", "shell", "--", prompt],
346
+ {
347
+ cwd: workspaceRoot,
348
+ encoding: "utf-8",
349
+ shell: process.platform === "win32",
350
+ timeout: 120_000,
351
+ input: "\n", // auto-accept any interactive prompt
352
+ maxBuffer: 4 * 1024 * 1024,
353
+ },
354
+ );
355
+ } else if (variant === "legacy") {
356
+ // Legacy @githubnext/github-copilot-cli binary
357
+ result = spawnSync(
358
+ "copilot",
359
+ ["-C", workspaceRoot, "-p", prompt, "--no-color", "-s"],
360
+ {
361
+ encoding: "utf-8",
362
+ shell: process.platform === "win32",
363
+ timeout: 120_000,
364
+ input: "",
365
+ maxBuffer: 4 * 1024 * 1024,
366
+ },
367
+ );
368
+ } else {
369
+ return null;
370
+ }
371
+
372
+ if (!result || result.status !== 0 || result.error) return null;
373
+ const text = (result.stdout || "").trim();
374
+ return text.length > 50 ? text : null;
375
+ }
376
+
276
377
  function enrichWithCopilotCli(
277
378
  workspaceRoot: string,
278
379
  contextFiles: string[],
@@ -286,45 +387,37 @@ function enrichWithCopilotCli(
286
387
 
287
388
  for (let i = 0; i < filesToProcess.length; i++) {
288
389
  const absPath = filesToProcess[i];
289
- const relPath = absPath.replace(`${workspaceRoot}/`, "").replace(`${workspaceRoot}\\`, "");
390
+ // Normalise to forward slashes so the prompt is readable on all platforms.
391
+ const relPath = absPath
392
+ .replace(`${workspaceRoot}\\`, "")
393
+ .replace(`${workspaceRoot}/`, "")
394
+ .replace(/\\/g, "/");
290
395
 
291
- // Show per-file progress so the user knows work is happening.
292
396
  process.stdout.write(` [${i + 1}/${total}] Enriching ${relPath}…`);
293
397
 
294
- const prompt = [
295
- `Update the markdown file at path '${relPath}' in place.`,
296
- "Goal: make it both human-understandable and LLM-ready for automation framework reasoning.",
297
- "Requirements:",
298
- "- Keep heading style consistent.",
299
- "- Include: purpose, architecture role, key assets, dependencies, conventions, risks, and update guidance.",
300
- "- Be concrete and repository-aware.",
301
- "- Do not modify any file other than the target file.",
302
- "- Preserve valid markdown.",
303
- ].join("\n");
304
-
305
- const result = spawnSync(
306
- "copilot",
307
- [
308
- "-C", workspaceRoot,
309
- "-p", prompt,
310
- "--allow-all-tools",
311
- "--allow-all-paths",
312
- "--no-color",
313
- "-s",
314
- ],
315
- {
316
- encoding: "utf-8",
317
- shell: process.platform === "win32",
318
- timeout: 120_000, // 2 min per file — prevents indefinite hangs
319
- input: "", // close stdin immediately
320
- },
321
- );
398
+ let currentContent = "";
399
+ try {
400
+ currentContent = readFileSync(absPath, "utf-8");
401
+ } catch {
402
+ process.stdout.write(" (unreadable)\n");
403
+ failed += 1;
404
+ continue;
405
+ }
322
406
 
323
- if (result.status === 0 && !result.error) {
324
- process.stdout.write(" ✓\n");
325
- updated += 1;
407
+ const enriched = tryGhCopilotSuggest(workspaceRoot, relPath, currentContent);
408
+ if (enriched) {
409
+ try {
410
+ writeFileSync(absPath, enriched, "utf-8");
411
+ process.stdout.write(" ✓\n");
412
+ updated += 1;
413
+ } catch {
414
+ process.stdout.write(" ✗ (write failed)\n");
415
+ failed += 1;
416
+ }
326
417
  } else {
327
- process.stdout.write(" ✗\n");
418
+ // Copilot returned empty/trivial output — not a hard failure.
419
+ // The Python AST enricher will handle these in the fallback pass.
420
+ process.stdout.write(" ✗ (empty response)\n");
328
421
  failed += 1;
329
422
  }
330
423
  }
@@ -499,19 +592,70 @@ program
499
592
  const allContextFiles = listFrameworkContextFiles(workspaceRoot);
500
593
  if (allContextFiles.length === 0) {
501
594
  console.log(chalk.yellow(" ⚠ No context.md files found for Copilot enrichment"));
502
- } else if (!isCopilotCliAvailable()) {
503
- console.log(chalk.yellow(" ⚠ Copilot CLI not installed; skipping Copilot enrichment"));
504
- } else if (!ensureCopilotLogin(workspaceRoot)) {
505
- console.log(chalk.yellow(" ⚠ Copilot login was not completed; skipping Copilot enrichment"));
506
595
  } else {
507
596
  const cap = Math.min(allContextFiles.length, MAX_ENRICHMENT_FILES);
508
597
  if (allContextFiles.length > MAX_ENRICHMENT_FILES) {
509
598
  console.log(chalk.gray(` - ${allContextFiles.length} context files found; enriching first ${MAX_ENRICHMENT_FILES} (re-run to continue)`));
510
599
  }
511
- const run = enrichWithCopilotCli(workspaceRoot, allContextFiles);
512
- console.log(chalk.green(` ✓ Copilot updated ${run.updated}/${cap} context.md file(s)`));
513
- if (run.failed > 0) {
514
- console.log(chalk.yellow(` ⚠ Copilot failed to update ${run.failed} context.md file(s)`));
600
+
601
+ const copilotVariant = detectCopilotVariant();
602
+ let copilotUpdated = 0;
603
+ let copilotFailed = 0;
604
+
605
+ if (!copilotVariant) {
606
+ console.log(chalk.gray(" - GitHub Copilot CLI not found; using Python AST enricher directly"));
607
+ } else if (!ensureCopilotLogin(workspaceRoot)) {
608
+ console.log(chalk.yellow(" ⚠ Copilot login was not completed; using Python AST enricher as fallback"));
609
+ } else {
610
+ // Primary strategy: Copilot CLI
611
+ const run = enrichWithCopilotCli(workspaceRoot, allContextFiles);
612
+ copilotUpdated = run.updated;
613
+ copilotFailed = run.failed;
614
+ if (copilotUpdated > 0) {
615
+ console.log(chalk.green(` ✓ Copilot updated ${copilotUpdated}/${cap} context.md file(s)`));
616
+ }
617
+ }
618
+
619
+ // Fallback / supplementary: Python AST enricher.
620
+ // Runs when Copilot is unavailable OR Copilot returned empty responses.
621
+ // The Python enricher does not require Copilot — it uses AST analysis
622
+ // and the Azure OpenAI key already configured in the project.
623
+ const needsFallback = copilotFailed > 0 || copilotUpdated === 0;
624
+ if (needsFallback) {
625
+ const skippedFiles = allContextFiles
626
+ .slice(0, MAX_ENRICHMENT_FILES)
627
+ .filter((f) => {
628
+ // Only send files that Copilot did NOT update (i.e. the ones that failed).
629
+ // We approximate this as: all files when copilotUpdated=0,
630
+ // or the trailing copilotFailed files otherwise.
631
+ return true; // enrich_framework_context is idempotent — safe to re-enrich
632
+ });
633
+ if (skippedFiles.length > 0) {
634
+ if (copilotVariant) {
635
+ console.log(chalk.gray(` - Falling back to Python AST enricher for ${copilotFailed} file(s) Copilot missed…`));
636
+ } else {
637
+ console.log(chalk.gray(` - Enriching ${Math.min(skippedFiles.length, MAX_ENRICHMENT_FILES)} context.md file(s) via Python AST enricher…`));
638
+ }
639
+ try {
640
+ const enrichResult = await runWorkflow("enrich_framework_context", {
641
+ repo_path: workspaceRoot,
642
+ context_paths: skippedFiles.slice(0, MAX_ENRICHMENT_FILES),
643
+ }) as Record<string, unknown>;
644
+ const pyUpdated = Number(enrichResult.updated ?? 0);
645
+ const pyFailed = Number(enrichResult.failed ?? 0);
646
+ if (pyUpdated > 0) {
647
+ console.log(chalk.green(` ✓ Python AST enricher updated ${pyUpdated} context.md file(s)`));
648
+ }
649
+ if (pyFailed > 0) {
650
+ console.log(chalk.yellow(` ⚠ Python AST enricher could not update ${pyFailed} file(s) (DB may be offline — files are still readable)`));
651
+ }
652
+ } catch (pyErr: unknown) {
653
+ // Python enricher failed (likely DB offline) — not fatal.
654
+ // Context files still have the scaffolded template content.
655
+ console.log(chalk.yellow(` ⚠ Python enricher skipped: ${pyErr instanceof Error ? pyErr.message : String(pyErr)}`) );
656
+ console.log(chalk.gray(" - Context files contain template scaffolding; enrichment can be re-run after DB is configured."));
657
+ }
658
+ }
515
659
  }
516
660
  }
517
661
  } catch (e: unknown) {
@@ -697,6 +841,22 @@ program
697
841
  if (options.json) {
698
842
  console.log(JSON.stringify(result, null, 2));
699
843
  } else {
844
+ // DB-offline / degraded state — return a helpful message rather than nothing
845
+ if (result.db_offline || result.error) {
846
+ const msg = String(result.message || result.error || "Unknown error");
847
+ if (result.db_offline) {
848
+ console.log(chalk.yellow("\n⚠ Database not reachable"));
849
+ console.log(chalk.gray(` ${msg}`));
850
+ console.log(chalk.gray("\n Checklist:"));
851
+ console.log(chalk.gray(" 1. Ensure PostgreSQL is running on 127.0.0.1:5432"));
852
+ console.log(chalk.gray(" 2. Set DB_PASSWORD (and optionally DB_USER, DB_NAME) in your .env or shell"));
853
+ console.log(chalk.gray(" 3. Re-run: igel-qe init"));
854
+ } else {
855
+ console.log(chalk.yellow(`\n⚠ Status unavailable: ${msg}`));
856
+ }
857
+ return;
858
+ }
859
+
700
860
  const state = (result as Record<string, unknown>);
701
861
  console.log(chalk.bold("\nProject State"));
702
862
  const ps = state.project_state as Record<string, number> | undefined;
@@ -27,18 +27,32 @@ _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
27
27
  if str(_PROJECT_ROOT) not in sys.path:
28
28
  sys.path.insert(0, str(_PROJECT_ROOT))
29
29
 
30
- from src.db.igel_schema import ensure_igel_tables
31
- from src.connectors.jira_connector import JiraConnector
32
- from src.agents.workflow_engine import WorkflowEngine
33
- from src.intelligence.diff_analyzer import analyze_diff_scope
34
- from src.intelligence.routing_engine import RouteInputs, compute_weighted_score, classify_route
35
- from src.agents.framework_analyzer_agent import FrameworkAnalyzerAgent
36
- from src.agents.orchestrator.orchestrator import Orchestrator
37
- from src.agents.internal.approval_manager import ApprovalError
38
- from src.config import cfg
39
- from src.project.context_projection_builder import ContextProjectionBuilder
40
- from src.project.context_requirement_sync import sync_requirement_context
41
- from src.project.context_enricher import enrich_context_files
30
+ try:
31
+ from src.db.igel_schema import ensure_igel_tables
32
+ from src.connectors.jira_connector import JiraConnector
33
+ from src.agents.workflow_engine import WorkflowEngine
34
+ from src.intelligence.diff_analyzer import analyze_diff_scope
35
+ from src.intelligence.routing_engine import RouteInputs, compute_weighted_score, classify_route
36
+ from src.agents.framework_analyzer_agent import FrameworkAnalyzerAgent
37
+ from src.agents.orchestrator.orchestrator import Orchestrator
38
+ from src.agents.internal.approval_manager import ApprovalError
39
+ from src.config import cfg
40
+ from src.project.context_projection_builder import ContextProjectionBuilder
41
+ from src.project.context_requirement_sync import sync_requirement_context
42
+ from src.project.context_enricher import enrich_context_files
43
+ _DEPS_AVAILABLE = True
44
+ _DEPS_ERROR: str | None = None
45
+ except ImportError as _ie:
46
+ _DEPS_AVAILABLE = False
47
+ _DEPS_ERROR = (
48
+ f"Missing Python dependency: {_ie}.\n"
49
+ "Run: igel-qe init --with-copilot to install required packages."
50
+ )
51
+ # Define stubs so references below don't cause NameErrors
52
+ ensure_igel_tables = JiraConnector = WorkflowEngine = None # type: ignore[assignment,misc]
53
+ analyze_diff_scope = RouteInputs = compute_weighted_score = classify_route = None # type: ignore[assignment]
54
+ FrameworkAnalyzerAgent = Orchestrator = ApprovalError = cfg = None # type: ignore[assignment,misc]
55
+ ContextProjectionBuilder = sync_requirement_context = enrich_context_files = None # type: ignore[assignment]
42
56
 
43
57
  logging.basicConfig(level=logging.ERROR) # Only output strict JSON to stdout
44
58
 
@@ -99,8 +113,39 @@ def main():
99
113
  except json.JSONDecodeError:
100
114
  _err("Invalid JSON args")
101
115
 
116
+ # ── Dependency guard ───────────────────────────────────────────────────────
117
+ # If core Python packages (e.g. psycopg2) are missing, return a structured
118
+ # warning for init_check so the CLI can surface a helpful message rather
119
+ # than crashing with a raw traceback.
120
+ if not _DEPS_AVAILABLE:
121
+ if args.action in ("init_project", "init_check"):
122
+ print(json.dumps({
123
+ "status": "success",
124
+ "ready": False,
125
+ "required_failed": ["python_dependencies"],
126
+ "warnings": [],
127
+ "checks": {
128
+ "python_dependencies": {
129
+ "status": "error",
130
+ "message": _DEPS_ERROR or "Required Python packages not installed. Run: igel-qe init --with-copilot",
131
+ }
132
+ },
133
+ }))
134
+ sys.exit(0)
135
+ else:
136
+ _err(_DEPS_ERROR or "Required Python packages not installed. Run: igel-qe init --with-copilot")
137
+
102
138
  # ── DB init ────────────────────────────────────────────────────────────────
103
- if args.action not in ("submit_feedback",):
139
+ # Skip for init_check / init_project: those actions run run_checks() which
140
+ # already tests DB connectivity and reports a structured "postgres: fail"
141
+ # result instead of a hard crash. Calling ensure_igel_tables() here first
142
+ # would cause a non-zero exit before run_checks() ever runs, swallowing
143
+ # the structured error output that the Node CLI depends on.
144
+ # Skip for get_project_status: it reads from DB internally and returns a
145
+ # graceful "db_offline" response on failure — no schema init needed.
146
+ # Also skip for submit_feedback which manages its own connection.
147
+ _DB_SKIP_ACTIONS = ("submit_feedback", "init_check", "init_project", "get_project_status")
148
+ if args.action not in _DB_SKIP_ACTIONS:
104
149
  try:
105
150
  ensure_igel_tables()
106
151
  except Exception as e:
@@ -770,7 +815,9 @@ def main():
770
815
  projection = _best_effort_project_context(repo_path)
771
816
 
772
817
  _ok({
773
- "enriched_count": len(enriched),
818
+ "updated": len(enriched), # TS reads this for progress display
819
+ "failed": 0, # AST enricher does not partially fail
820
+ "enriched_count": len(enriched), # backward compat alias
774
821
  "enriched": [
775
822
  {
776
823
  "path": r.path,
@@ -787,7 +834,14 @@ def main():
787
834
  "context_projection": projection,
788
835
  })
789
836
  except Exception as e:
790
- _err(str(e))
837
+ # Return a partial-success response so TS can show a warning instead of crashing.
838
+ # Common failure: DB offline (context files are still written to disk).
839
+ _ok({
840
+ "updated": 0,
841
+ "failed": -1, # signals error condition to TS
842
+ "error": str(e),
843
+ "enriched_count": 0,
844
+ })
791
845
 
792
846
  # ── update_context_file ─────────────────────────────────────────────────
793
847
  # Accepts: {
@@ -932,9 +986,21 @@ def main():
932
986
  summary = rebuild_system_state_from_db(root)
933
987
  _ok(summary)
934
988
  except Exception as e:
935
- _err(str(e))
989
+ # Return graceful degraded status so `igel-qe status` doesn't crash
990
+ # when DB is offline — users should still get a useful message.
991
+ err_msg = str(e)
992
+ db_offline = "password" in err_msg.lower() or "connection" in err_msg.lower() or "psycopg" in err_msg.lower()
993
+ _ok({
994
+ "db_offline": db_offline,
995
+ "error": err_msg,
996
+ "project_state": None,
997
+ "sync_state": None,
998
+ "message": (
999
+ "Database is offline or not configured. "
1000
+ "Set DB_PASSWORD / DATABASE_URL in your environment and re-run igel-qe init."
1001
+ ) if db_offline else err_msg,
1002
+ })
936
1003
 
937
1004
 
938
1005
  if __name__ == "__main__":
939
1006
  main()
940
-
package/src/db/client.py CHANGED
@@ -3,15 +3,31 @@ from __future__ import annotations
3
3
  import logging
4
4
  import os
5
5
 
6
- import psycopg2
7
- import psycopg2.pool
6
+ try:
7
+ import psycopg2
8
+ import psycopg2.pool
9
+ _PSYCOPG2_AVAILABLE = True
10
+ except ImportError:
11
+ psycopg2 = None # type: ignore[assignment]
12
+ _PSYCOPG2_AVAILABLE = False
13
+
8
14
  from contextlib import contextmanager
9
15
 
10
16
  from src.config import cfg
11
17
 
12
18
  logger = logging.getLogger(__name__)
13
19
 
14
- _pool: psycopg2.pool.ThreadedConnectionPool | None = None
20
+ _pool: "psycopg2.pool.ThreadedConnectionPool | None" = None
21
+
22
+
23
+ def _require_psycopg2() -> None:
24
+ """Raise a clear error if psycopg2 is not installed."""
25
+ if not _PSYCOPG2_AVAILABLE:
26
+ raise ImportError(
27
+ "psycopg2 is not installed. Run: pip install psycopg2-binary\n"
28
+ "or re-run: igel-qe init --with-copilot (which will retry the install)"
29
+ )
30
+
15
31
 
16
32
 
17
33
  def _pool_kwargs() -> dict:
@@ -32,6 +48,7 @@ def _pool_kwargs() -> dict:
32
48
 
33
49
 
34
50
  def init_pool(minconn: int = 2, maxconn: int = 10) -> None:
51
+ _require_psycopg2()
35
52
  global _pool
36
53
  if _pool is not None:
37
54
  return
@@ -49,7 +49,8 @@ beautifulsoup4>=4.12.0
49
49
  lxml>=5.0.0
50
50
  duckduckgo-search>=6.0.0,<7.0.0; python_version < "3.9"
51
51
  firecrawl-py>=1.0.0
52
- playwright>=1.40.0,<1.49.0
52
+ # playwright: no upper bound — crawl4ai>=0.4 requires playwright>=1.49
53
+ playwright>=1.49.0
53
54
  crawl4ai>=0.4.0,<0.5.0; python_version >= "3.9"
54
55
 
55
56
  # REST API server (always-running service)