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.
- package/extensions/cleave/index.ts +130 -56
- package/extensions/openspec/spec.ts +3 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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:
|
|
464
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|