omegon 0.8.2 → 0.8.4

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
+ export 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,102 @@ 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 resolution action.
396
439
  let currentClassification = classification;
440
+ // Only resolution actions (checkpoint, stash) increment this counter.
441
+ // Invalid input, empty guards, cancel, and proceed-without-cleave do NOT
442
+ // consume attempts — they are navigational, not resolution attempts.
443
+ let resolutionAttempts = 0;
444
+
445
+ // Outer safety cap: total loop iterations including non-resolution turns.
446
+ // Prevents truly pathological loops (e.g. select always returning garbage).
447
+ const MAX_TOTAL_ITERATIONS = MAX_PREFLIGHT_ATTEMPTS * 3;
448
+ let totalIterations = 0;
449
+
450
+ while (resolutionAttempts < MAX_PREFLIGHT_ATTEMPTS) {
451
+ totalIterations++;
452
+ if (totalIterations > MAX_TOTAL_ITERATIONS) {
453
+ break; // Fall through to the exhaustion error below
454
+ }
397
455
 
398
- while (true) {
399
456
  let answer: string | undefined;
400
457
  if (hasSelect) {
401
- // Preferred path: show a modal select with labelled options and descriptions.
402
458
  const selected = await options.ui!.select!(
403
459
  "Dirty tree detected — choose a preflight action to proceed:",
404
460
  [...PREFLIGHT_ACTION_OPTIONS],
405
461
  );
406
462
  answer = parsePreflightAction(selected);
407
463
  } else {
408
- // Fallback: raw text input (headless / test environments).
409
464
  answer = normalizePreflightInput(
410
465
  await options.ui!.input!("Dirty tree action [checkpoint|stash-unrelated|stash-volatile|proceed-without-cleave|cancel]:"),
411
466
  )?.toLowerCase();
412
467
  }
468
+
413
469
  try {
414
470
  switch (answer) {
415
471
  case "checkpoint": {
416
- // Rebuild the checkpoint plan from the current (possibly refreshed) classification (C4).
472
+ resolutionAttempts++;
417
473
  const currentCheckpointPlan = buildCheckpointPlan(currentClassification, { changeName, openspecContext });
418
- const committedFiles = new Set(currentClassification.checkpointFiles);
419
474
  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
475
 
431
- // Re-derive classification from the post-checkpoint state (C1).
432
- currentClassification = classifyPreflightDirtyPaths(
433
- postState.entries.map((e) => e.path),
434
- { changeName, openspecContext },
476
+ const { clean, classification: postClassification } = await verifyCleanAfterAction(
477
+ pi, options.repoPath, changeName, openspecContext, options.onUpdate,
435
478
  );
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.",
479
+ if (clean) return "continue";
480
+
481
+ // Dirty files remain after checkpoint update classification and show diagnosis.
482
+ currentClassification = postClassification!;
483
+ const remainingPaths = [
484
+ ...currentClassification.related,
485
+ ...currentClassification.unrelated,
486
+ ...currentClassification.unknown,
487
+ ...currentClassification.volatile,
461
488
  ];
462
489
  options.onUpdate?.({
463
- content: [{ type: "text", text: diagnosisLines.join("\n") }],
464
- details: { phase: "preflight", postCheckpointDirty: remainingPaths },
490
+ content: [{ type: "text", text:
491
+ "Checkpoint committed, but dirty files remain:\n" +
492
+ remainingPaths.map((f) => ` • ${f.path} [${f.reason}]`).join("\n") +
493
+ "\n\nChoose another action to resolve.",
494
+ }],
495
+ details: { phase: "preflight", postCheckpointDirty: remainingPaths.map((f) => f.path) },
465
496
  });
466
497
  break;
467
498
  }
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":
499
+ case "stash-unrelated": {
500
+ const toStash = [...currentClassification.unrelated, ...currentClassification.unknown];
501
+ if (toStash.length === 0) {
502
+ options.onUpdate?.({
503
+ content: [{ type: "text", text: "No unrelated or unknown files to stash. Choose a different action." }],
504
+ details: { phase: "preflight" },
505
+ });
506
+ break;
507
+ }
508
+ resolutionAttempts++;
509
+ await stashPaths(pi, options.repoPath, "cleave-preflight-unrelated", toStash);
510
+ const { clean, classification: postClassification } = await verifyCleanAfterAction(
511
+ pi, options.repoPath, changeName, openspecContext, options.onUpdate,
512
+ );
513
+ if (clean) return "continue";
514
+ currentClassification = postClassification!;
515
+ break;
516
+ }
517
+ case "stash-volatile": {
518
+ if (currentClassification.volatile.length === 0) {
519
+ options.onUpdate?.({
520
+ content: [{ type: "text", text: "No volatile files to stash. Choose a different action." }],
521
+ details: { phase: "preflight" },
522
+ });
523
+ break;
524
+ }
525
+ resolutionAttempts++;
473
526
  await stashPaths(pi, options.repoPath, "cleave-preflight-volatile", currentClassification.volatile);
474
- return "continue";
527
+ const { clean, classification: postClassification } = await verifyCleanAfterAction(
528
+ pi, options.repoPath, changeName, openspecContext, options.onUpdate,
529
+ );
530
+ if (clean) return "continue";
531
+ currentClassification = postClassification!;
532
+ break;
533
+ }
475
534
  case "proceed-without-cleave":
476
535
  return "skip_cleave";
477
536
  case "cancel":
@@ -479,11 +538,13 @@ export async function runDirtyTreePreflight(pi: ExtensionAPI, options: DirtyTree
479
538
  return "cancelled";
480
539
  default:
481
540
  options.onUpdate?.({
482
- content: [{ type: "text", text: "Invalid preflight action. Choose checkpoint, stash-unrelated, stash-volatile, proceed-without-cleave, or cancel." }],
541
+ content: [{ type: "text", text: "Invalid action. Choose checkpoint, stash-unrelated, stash-volatile, proceed-without-cleave, or cancel." }],
483
542
  details: { phase: "preflight" },
484
543
  });
485
544
  }
486
545
  } catch (error) {
546
+ // Resolution action threw (e.g. git commit failed) — still counts
547
+ // as a resolution attempt since work was attempted.
487
548
  const message = error instanceof Error ? error.message : String(error);
488
549
  options.onUpdate?.({
489
550
  content: [{ type: "text", text: `Preflight action failed: ${message}` }],
@@ -491,6 +552,19 @@ export async function runDirtyTreePreflight(pi: ExtensionAPI, options: DirtyTree
491
552
  });
492
553
  }
493
554
  }
555
+
556
+ // Exhausted resolution attempts — report remaining dirty files and bail.
557
+ const remaining = [
558
+ ...currentClassification.related,
559
+ ...currentClassification.unrelated,
560
+ ...currentClassification.unknown,
561
+ ...currentClassification.volatile,
562
+ ];
563
+ throw new Error(
564
+ `Dirty tree not resolved after ${resolutionAttempts} resolution attempt(s). Remaining files:\n` +
565
+ remaining.map((f) => ` • ${f.path}`).join("\n") +
566
+ "\n\nResolve manually (git commit/stash/checkout) and retry /cleave.",
567
+ );
494
568
  }
495
569
 
496
570
  interface AssessExecutionContext {
@@ -432,6 +432,9 @@ export function computeAssessmentSnapshot(repoPath: string, changeName: string):
432
432
  // assessment.json (which lives in-repo) changes HEAD, which invalidates
433
433
  // the fingerprint, making the assessment permanently stale.
434
434
  // Git HEAD is stored separately in the snapshot for informational use.
435
+ // The `dirty` flag still captures uncommitted-change state, which is
436
+ // the meaningful signal — it detects when scoped files have been
437
+ // modified since the last commit without requiring HEAD identity.
435
438
  const fingerprintSeed = JSON.stringify({
436
439
  changeName,
437
440
  dirty,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omegon",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
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",