omegon 0.8.2 → 0.8.3

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.
@@ -347,6 +347,50 @@ async function checkpointRelatedChanges(
347
347
  }
348
348
  }
349
349
 
350
+ /**
351
+ * Maximum number of interactive preflight attempts before bailing.
352
+ * Prevents infinite loops when the agent repeatedly picks actions
353
+ * that don't resolve the dirty tree.
354
+ */
355
+ const MAX_PREFLIGHT_ATTEMPTS = 3;
356
+
357
+ /**
358
+ * Verify the tree is clean after an action. If only volatile files remain,
359
+ * auto-stash them. Returns true if the tree is resolved, false if dirty
360
+ * files remain.
361
+ */
362
+ async function verifyCleanAfterAction(
363
+ pi: ExtensionAPI,
364
+ repoPath: string,
365
+ changeName: string | null,
366
+ openspecContext: OpenSpecContext | null,
367
+ onUpdate?: AgentToolUpdateCallback<Record<string, unknown>>,
368
+ ): Promise<{ clean: boolean; classification: WorkspaceDirtyTreeClassification | null }> {
369
+ const postStatus = await pi.exec("git", ["status", "--porcelain"], {
370
+ cwd: repoPath,
371
+ timeout: 5_000,
372
+ });
373
+ const postState = inspectGitState(postStatus.stdout);
374
+ if (postState.entries.length === 0) return { clean: true, classification: null };
375
+
376
+ const postClassification = classifyPreflightDirtyPaths(
377
+ postState.entries.map((e) => e.path),
378
+ { changeName, openspecContext },
379
+ );
380
+
381
+ // If only volatile files remain, auto-stash and treat as clean.
382
+ if (postState.nonVolatile.length === 0 && postClassification.volatile.length > 0) {
383
+ await stashPaths(pi, repoPath, "cleave-preflight-volatile", postClassification.volatile);
384
+ onUpdate?.({
385
+ content: [{ type: "text", text: "Remaining volatile artifacts stashed automatically — tree is clean." }],
386
+ details: { phase: "preflight", autoResolved: "volatile_only_stash" },
387
+ });
388
+ return { clean: true, classification: null };
389
+ }
390
+
391
+ return { clean: false, classification: postClassification };
392
+ }
393
+
350
394
  export async function runDirtyTreePreflight(pi: ExtensionAPI, options: DirtyTreePreflightOptions): Promise<"continue" | "skip_cleave" | "cancelled"> {
351
395
  const status = await pi.exec("git", ["status", "--porcelain"], {
352
396
  cwd: options.repoPath,
@@ -369,12 +413,11 @@ export async function runDirtyTreePreflight(pi: ExtensionAPI, options: DirtyTree
369
413
  changeName,
370
414
  openspecContext,
371
415
  });
372
- // Compute initial checkpoint plan for the summary display only.
373
- // The plan is rebuilt from currentClassification inside the loop on each attempt (C4).
374
416
  const initialCheckpointPlan = buildCheckpointPlan(classification, { changeName, openspecContext });
375
417
  const summary = formatDirtyTreeSummary(classification, initialCheckpointPlan.message);
376
418
  options.onUpdate?.({ content: [{ type: "text", text: summary }], details: { phase: "preflight" } });
377
419
 
420
+ // Auto-resolve: volatile-only dirty tree — stash and continue without prompting.
378
421
  if (gitState.nonVolatile.length === 0) {
379
422
  if (classification.volatile.length > 0) {
380
423
  await stashPaths(pi, options.repoPath, "cleave-preflight-volatile", classification.volatile);
@@ -392,86 +435,88 @@ export async function runDirtyTreePreflight(pi: ExtensionAPI, options: DirtyTree
392
435
  throw new Error(summary + "\n\nInteractive input is unavailable, so cleave cannot resolve the dirty tree automatically.");
393
436
  }
394
437
 
395
- // Mutable classification — refreshed after each checkpoint attempt (C1/W1).
438
+ // Mutable classification — refreshed after each action attempt.
396
439
  let currentClassification = classification;
440
+ let attempts = 0;
441
+
442
+ while (attempts < MAX_PREFLIGHT_ATTEMPTS) {
443
+ attempts++;
397
444
 
398
- while (true) {
399
445
  let answer: string | undefined;
400
446
  if (hasSelect) {
401
- // Preferred path: show a modal select with labelled options and descriptions.
402
447
  const selected = await options.ui!.select!(
403
448
  "Dirty tree detected — choose a preflight action to proceed:",
404
449
  [...PREFLIGHT_ACTION_OPTIONS],
405
450
  );
406
451
  answer = parsePreflightAction(selected);
407
452
  } else {
408
- // Fallback: raw text input (headless / test environments).
409
453
  answer = normalizePreflightInput(
410
454
  await options.ui!.input!("Dirty tree action [checkpoint|stash-unrelated|stash-volatile|proceed-without-cleave|cancel]:"),
411
455
  )?.toLowerCase();
412
456
  }
457
+
413
458
  try {
414
459
  switch (answer) {
415
460
  case "checkpoint": {
416
- // Rebuild the checkpoint plan from the current (possibly refreshed) classification (C4).
417
461
  const currentCheckpointPlan = buildCheckpointPlan(currentClassification, { changeName, openspecContext });
418
- const committedFiles = new Set(currentClassification.checkpointFiles);
419
462
  await checkpointRelatedChanges(pi, options.repoPath, currentClassification, currentCheckpointPlan.message, options.ui);
420
- // Re-verify cleanliness after the checkpoint commit.
421
- const postCheckpointStatus = await pi.exec("git", ["status", "--porcelain"], {
422
- cwd: options.repoPath,
423
- timeout: 5_000,
424
- });
425
- const postState = inspectGitState(postCheckpointStatus.stdout);
426
- if (postState.entries.length === 0) {
427
- // Tree is clean — checkpoint fully resolved the dirty tree.
428
- return "continue";
429
- }
430
463
 
431
- // Re-derive classification from the post-checkpoint state (C1).
432
- currentClassification = classifyPreflightDirtyPaths(
433
- postState.entries.map((e) => e.path),
434
- { changeName, openspecContext },
464
+ const { clean, classification: postClassification } = await verifyCleanAfterAction(
465
+ pi, options.repoPath, changeName, openspecContext, options.onUpdate,
435
466
  );
436
-
437
- // C2: If only volatile files remain, auto-stash and continue.
438
- if (postState.nonVolatile.length === 0 && currentClassification.volatile.length > 0) {
439
- await stashPaths(pi, options.repoPath, "cleave-preflight-volatile", currentClassification.volatile);
440
- options.onUpdate?.({
441
- content: [{ type: "text", text: "Checkpoint succeeded. Remaining volatile artifacts stashed automatically — cleave continuing." }],
442
- details: { phase: "preflight", autoResolved: "volatile_only_stash" },
443
- });
444
- return "continue";
445
- }
446
-
447
- // Remaining dirty files — emit precise diagnosis (W1: distinguish committed-but-still-dirty vs excluded-from-scope).
448
- const remainingPaths = postState.entries.map((e) => e.path);
449
- const diagnosisLines = [
450
- "Checkpoint committed successfully, but dirty files remain — cleave cannot continue yet:",
451
- ...currentClassification.related.map((f) =>
452
- committedFiles.has(f.path)
453
- ? ` • ${f.path} [was committed but remains dirty — file may have been modified after staging or only partially staged]`
454
- : ` • ${f.path} [related but excluded from checkpoint scope — confidence too low to commit automatically]`
455
- ),
456
- ...currentClassification.unrelated.map((f) => ` • ${f.path} [unrelated: ${f.reason}]`),
457
- ...currentClassification.unknown.map((f) => ` • ${f.path} [unknown — not in change scope, was not checkpointed]`),
458
- ...currentClassification.volatile.map((f) => ` • ${f.path} [volatile artifact — will be auto-stashed]`),
459
- "",
460
- "Choose another preflight action to resolve the remaining files.",
467
+ if (clean) return "continue";
468
+
469
+ // Dirty files remain after checkpoint update classification and show diagnosis.
470
+ currentClassification = postClassification!;
471
+ const remainingPaths = [
472
+ ...currentClassification.related,
473
+ ...currentClassification.unrelated,
474
+ ...currentClassification.unknown,
475
+ ...currentClassification.volatile,
461
476
  ];
462
477
  options.onUpdate?.({
463
- content: [{ type: "text", text: diagnosisLines.join("\n") }],
464
- details: { phase: "preflight", postCheckpointDirty: remainingPaths },
478
+ content: [{ type: "text", text:
479
+ "Checkpoint committed, but dirty files remain:\n" +
480
+ remainingPaths.map((f) => ` • ${f.path} [${f.reason}]`).join("\n") +
481
+ "\n\nChoose another action to resolve.",
482
+ }],
483
+ details: { phase: "preflight", postCheckpointDirty: remainingPaths.map((f) => f.path) },
465
484
  });
466
485
  break;
467
486
  }
468
- case "stash-unrelated":
469
- // C1: Use currentClassification (refreshed after checkpoint) not the stale original.
470
- await stashPaths(pi, options.repoPath, "cleave-preflight-unrelated", [...currentClassification.unrelated, ...currentClassification.unknown]);
471
- return "continue";
472
- case "stash-volatile":
487
+ case "stash-unrelated": {
488
+ const toStash = [...currentClassification.unrelated, ...currentClassification.unknown];
489
+ if (toStash.length === 0) {
490
+ options.onUpdate?.({
491
+ content: [{ type: "text", text: "No unrelated or unknown files to stash. Choose a different action." }],
492
+ details: { phase: "preflight" },
493
+ });
494
+ break;
495
+ }
496
+ await stashPaths(pi, options.repoPath, "cleave-preflight-unrelated", toStash);
497
+ const { clean, classification: postClassification } = await verifyCleanAfterAction(
498
+ pi, options.repoPath, changeName, openspecContext, options.onUpdate,
499
+ );
500
+ if (clean) return "continue";
501
+ currentClassification = postClassification!;
502
+ break;
503
+ }
504
+ case "stash-volatile": {
505
+ if (currentClassification.volatile.length === 0) {
506
+ options.onUpdate?.({
507
+ content: [{ type: "text", text: "No volatile files to stash. Choose a different action." }],
508
+ details: { phase: "preflight" },
509
+ });
510
+ break;
511
+ }
473
512
  await stashPaths(pi, options.repoPath, "cleave-preflight-volatile", currentClassification.volatile);
474
- return "continue";
513
+ const { clean, classification: postClassification } = await verifyCleanAfterAction(
514
+ pi, options.repoPath, changeName, openspecContext, options.onUpdate,
515
+ );
516
+ if (clean) return "continue";
517
+ currentClassification = postClassification!;
518
+ break;
519
+ }
475
520
  case "proceed-without-cleave":
476
521
  return "skip_cleave";
477
522
  case "cancel":
@@ -479,7 +524,7 @@ export async function runDirtyTreePreflight(pi: ExtensionAPI, options: DirtyTree
479
524
  return "cancelled";
480
525
  default:
481
526
  options.onUpdate?.({
482
- content: [{ type: "text", text: "Invalid preflight action. Choose checkpoint, stash-unrelated, stash-volatile, proceed-without-cleave, or cancel." }],
527
+ content: [{ type: "text", text: "Invalid action. Choose checkpoint, stash-unrelated, stash-volatile, proceed-without-cleave, or cancel." }],
483
528
  details: { phase: "preflight" },
484
529
  });
485
530
  }
@@ -491,6 +536,19 @@ export async function runDirtyTreePreflight(pi: ExtensionAPI, options: DirtyTree
491
536
  });
492
537
  }
493
538
  }
539
+
540
+ // Exhausted attempts — report remaining dirty files and bail.
541
+ const remaining = [
542
+ ...currentClassification.related,
543
+ ...currentClassification.unrelated,
544
+ ...currentClassification.unknown,
545
+ ...currentClassification.volatile,
546
+ ];
547
+ throw new Error(
548
+ `Dirty tree not resolved after ${MAX_PREFLIGHT_ATTEMPTS} attempts. Remaining files:\n` +
549
+ remaining.map((f) => ` • ${f.path}`).join("\n") +
550
+ "\n\nResolve manually (git commit/stash/checkout) and retry /cleave.",
551
+ );
494
552
  }
495
553
 
496
554
  interface AssessExecutionContext {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omegon",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Omegon — an opinionated distribution of pi (by Mario Zechner) with extensions for lifecycle management, memory, orchestration, and visualization",
5
5
  "bin": {
6
6
  "omegon": "bin/omegon.mjs",