igel-qe-core 1.0.18 → 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
@@ -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
+ });
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,
229
237
  });
230
- return check.status === 0 && !check.error;
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;
@@ -268,71 +350,32 @@ function enrichWithCopilotCli(workspaceRoot, contextFiles) {
268
350
  .replace(`${workspaceRoot}\\`, "")
269
351
  .replace(`${workspaceRoot}/`, "")
270
352
  .replace(/\\/g, "/");
271
- // Show per-file progress so the user knows work is happening.
272
353
  process.stdout.write(` [${i + 1}/${total}] Enriching ${relPath}…`);
273
- // Read the current (template) content so Copilot can see the structure.
274
354
  let currentContent = "";
275
355
  try {
276
356
  currentContent = readFileSync(absPath, "utf-8");
277
357
  }
278
358
  catch {
279
- // If we can't read, skip this file.
280
359
  process.stdout.write(" ✗ (unreadable)\n");
281
360
  failed += 1;
282
361
  continue;
283
362
  }
284
- // Build a prompt that asks Copilot to RETURN the improved markdown as plain
285
- // text to stdout. We write the file ourselves so we know it was updated.
286
- const prompt = [
287
- `You are enriching a context.md file for an automation test framework.`,
288
- `The file is located at: ${relPath} (relative to the workspace root).`,
289
- ``,
290
- `Current content:`,
291
- `\`\`\`markdown`,
292
- currentContent,
293
- `\`\`\``,
294
- ``,
295
- `Rewrite the content above to be concrete, human-understandable, and LLM-ready.`,
296
- `Requirements:`,
297
- `- Keep the same heading structure (# Context, ## Purpose, ## Key Assets, ## Conventions, ## Risks and Dependencies).`,
298
- `- Replace placeholder text with specific details inferred from the folder path and framework context.`,
299
- `- Be concise but informative. Each section: 2–5 bullet points or short sentences.`,
300
- `- Output ONLY the improved markdown. Do NOT add commentary, code fences, or extra text around it.`,
301
- ].join("\n");
302
- const result = spawnSync("copilot", [
303
- "-C", workspaceRoot,
304
- "-p", prompt,
305
- "--no-color",
306
- "-s",
307
- ], {
308
- encoding: "utf-8",
309
- shell: process.platform === "win32",
310
- timeout: 120_000, // 2 min per file — prevents indefinite hangs
311
- input: "", // close stdin immediately
312
- maxBuffer: 4 * 1024 * 1024, // 4 MB stdout buffer
313
- });
314
- if (result.status === 0 && !result.error) {
315
- const enriched = (result.stdout || "").trim();
316
- if (enriched.length > 50) {
317
- // Only write back if Copilot returned meaningful content (>50 chars).
318
- try {
319
- writeFileSync(absPath, enriched, "utf-8");
320
- process.stdout.write(" ✓\n");
321
- updated += 1;
322
- }
323
- catch {
324
- process.stdout.write(" ✗ (write failed)\n");
325
- failed += 1;
326
- }
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;
327
369
  }
328
- else {
329
- // Copilot ran but returned empty/trivial output.
330
- process.stdout.write(" ✗ (empty response)\n");
370
+ catch {
371
+ process.stdout.write(" (write failed)\n");
331
372
  failed += 1;
332
373
  }
333
374
  }
334
375
  else {
335
- 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");
336
379
  failed += 1;
337
380
  }
338
381
  }
@@ -501,21 +544,71 @@ program
501
544
  if (allContextFiles.length === 0) {
502
545
  console.log(chalk.yellow(" ⚠ No context.md files found for Copilot enrichment"));
503
546
  }
504
- else if (!isCopilotCliAvailable()) {
505
- console.log(chalk.yellow(" ⚠ Copilot CLI not installed; skipping Copilot enrichment"));
506
- }
507
- else if (!ensureCopilotLogin(workspaceRoot)) {
508
- console.log(chalk.yellow(" ⚠ Copilot login was not completed; skipping Copilot enrichment"));
509
- }
510
547
  else {
511
548
  const cap = Math.min(allContextFiles.length, MAX_ENRICHMENT_FILES);
512
549
  if (allContextFiles.length > MAX_ENRICHMENT_FILES) {
513
550
  console.log(chalk.gray(` - ${allContextFiles.length} context files found; enriching first ${MAX_ENRICHMENT_FILES} (re-run to continue)`));
514
551
  }
515
- const run = enrichWithCopilotCli(workspaceRoot, allContextFiles);
516
- console.log(chalk.green(` ✓ Copilot updated ${run.updated}/${cap} context.md file(s)`));
517
- if (run.failed > 0) {
518
- 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
+ }
519
612
  }
520
613
  }
521
614
  }
@@ -698,6 +791,22 @@ program
698
791
  console.log(JSON.stringify(result, null, 2));
699
792
  }
700
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
+ }
701
810
  const state = result;
702
811
  console.log(chalk.bold("\nProject State"));
703
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.18",
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
@@ -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,
248
+ });
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,
240
256
  });
241
- return check.status === 0 && !check.error;
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[],
@@ -292,75 +393,31 @@ function enrichWithCopilotCli(
292
393
  .replace(`${workspaceRoot}/`, "")
293
394
  .replace(/\\/g, "/");
294
395
 
295
- // Show per-file progress so the user knows work is happening.
296
396
  process.stdout.write(` [${i + 1}/${total}] Enriching ${relPath}…`);
297
397
 
298
- // Read the current (template) content so Copilot can see the structure.
299
398
  let currentContent = "";
300
399
  try {
301
400
  currentContent = readFileSync(absPath, "utf-8");
302
401
  } catch {
303
- // If we can't read, skip this file.
304
402
  process.stdout.write(" ✗ (unreadable)\n");
305
403
  failed += 1;
306
404
  continue;
307
405
  }
308
406
 
309
- // Build a prompt that asks Copilot to RETURN the improved markdown as plain
310
- // text to stdout. We write the file ourselves so we know it was updated.
311
- const prompt = [
312
- `You are enriching a context.md file for an automation test framework.`,
313
- `The file is located at: ${relPath} (relative to the workspace root).`,
314
- ``,
315
- `Current content:`,
316
- `\`\`\`markdown`,
317
- currentContent,
318
- `\`\`\``,
319
- ``,
320
- `Rewrite the content above to be concrete, human-understandable, and LLM-ready.`,
321
- `Requirements:`,
322
- `- Keep the same heading structure (# Context, ## Purpose, ## Key Assets, ## Conventions, ## Risks and Dependencies).`,
323
- `- Replace placeholder text with specific details inferred from the folder path and framework context.`,
324
- `- Be concise but informative. Each section: 2–5 bullet points or short sentences.`,
325
- `- Output ONLY the improved markdown. Do NOT add commentary, code fences, or extra text around it.`,
326
- ].join("\n");
327
-
328
- const result = spawnSync(
329
- "copilot",
330
- [
331
- "-C", workspaceRoot,
332
- "-p", prompt,
333
- "--no-color",
334
- "-s",
335
- ],
336
- {
337
- encoding: "utf-8",
338
- shell: process.platform === "win32",
339
- timeout: 120_000, // 2 min per file — prevents indefinite hangs
340
- input: "", // close stdin immediately
341
- maxBuffer: 4 * 1024 * 1024, // 4 MB stdout buffer
342
- },
343
- );
344
-
345
- if (result.status === 0 && !result.error) {
346
- const enriched = (result.stdout || "").trim();
347
- if (enriched.length > 50) {
348
- // Only write back if Copilot returned meaningful content (>50 chars).
349
- try {
350
- writeFileSync(absPath, enriched, "utf-8");
351
- process.stdout.write(" ✓\n");
352
- updated += 1;
353
- } catch {
354
- process.stdout.write(" ✗ (write failed)\n");
355
- failed += 1;
356
- }
357
- } else {
358
- // Copilot ran but returned empty/trivial output.
359
- process.stdout.write(" ✗ (empty response)\n");
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");
360
415
  failed += 1;
361
416
  }
362
417
  } else {
363
- 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");
364
421
  failed += 1;
365
422
  }
366
423
  }
@@ -535,19 +592,70 @@ program
535
592
  const allContextFiles = listFrameworkContextFiles(workspaceRoot);
536
593
  if (allContextFiles.length === 0) {
537
594
  console.log(chalk.yellow(" ⚠ No context.md files found for Copilot enrichment"));
538
- } else if (!isCopilotCliAvailable()) {
539
- console.log(chalk.yellow(" ⚠ Copilot CLI not installed; skipping Copilot enrichment"));
540
- } else if (!ensureCopilotLogin(workspaceRoot)) {
541
- console.log(chalk.yellow(" ⚠ Copilot login was not completed; skipping Copilot enrichment"));
542
595
  } else {
543
596
  const cap = Math.min(allContextFiles.length, MAX_ENRICHMENT_FILES);
544
597
  if (allContextFiles.length > MAX_ENRICHMENT_FILES) {
545
598
  console.log(chalk.gray(` - ${allContextFiles.length} context files found; enriching first ${MAX_ENRICHMENT_FILES} (re-run to continue)`));
546
599
  }
547
- const run = enrichWithCopilotCli(workspaceRoot, allContextFiles);
548
- console.log(chalk.green(` ✓ Copilot updated ${run.updated}/${cap} context.md file(s)`));
549
- if (run.failed > 0) {
550
- 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
+ }
551
659
  }
552
660
  }
553
661
  } catch (e: unknown) {
@@ -733,6 +841,22 @@ program
733
841
  if (options.json) {
734
842
  console.log(JSON.stringify(result, null, 2));
735
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
+
736
860
  const state = (result as Record<string, unknown>);
737
861
  console.log(chalk.bold("\nProject State"));
738
862
  const ps = state.project_state as Record<string, number> | undefined;
@@ -136,7 +136,16 @@ def main():
136
136
  _err(_DEPS_ERROR or "Required Python packages not installed. Run: igel-qe init --with-copilot")
137
137
 
138
138
  # ── DB init ────────────────────────────────────────────────────────────────
139
- 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:
140
149
  try:
141
150
  ensure_igel_tables()
142
151
  except Exception as e:
@@ -806,7 +815,9 @@ def main():
806
815
  projection = _best_effort_project_context(repo_path)
807
816
 
808
817
  _ok({
809
- "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
810
821
  "enriched": [
811
822
  {
812
823
  "path": r.path,
@@ -823,7 +834,14 @@ def main():
823
834
  "context_projection": projection,
824
835
  })
825
836
  except Exception as e:
826
- _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
+ })
827
845
 
828
846
  # ── update_context_file ─────────────────────────────────────────────────
829
847
  # Accepts: {
@@ -968,9 +986,21 @@ def main():
968
986
  summary = rebuild_system_state_from_db(root)
969
987
  _ok(summary)
970
988
  except Exception as e:
971
- _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
+ })
972
1003
 
973
1004
 
974
1005
  if __name__ == "__main__":
975
1006
  main()
976
-