@hamp10/agentforge 0.2.28 → 0.2.29

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hamp10/agentforge",
3
- "version": "0.2.28",
3
+ "version": "0.2.29",
4
4
  "description": "AgentForge worker — connect your machine to agentforge.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -625,6 +625,29 @@ export class OpenClawCLI extends EventEmitter {
625
625
  return true;
626
626
  }
627
627
 
628
+ _isDirectScopedReferenceSourcePath(filePath, workDir, relativePath, slugs, pageOnly, task) {
629
+ if (!pageOnly || !this._allowsDirectScopedPageSourcesToRemainDeleted(task)) return false;
630
+ if (!Array.isArray(slugs) || slugs.length === 0) return false;
631
+ if (!this._isDirectPageSourcePath(relativePath)) return false;
632
+
633
+ const relative = path.relative(workDir, filePath);
634
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return false;
635
+
636
+ const contents = [];
637
+ try {
638
+ if (existsSync(filePath)) contents.push(readFileSync(filePath, 'utf-8'));
639
+ } catch {}
640
+ try {
641
+ contents.push(String(execFileSync('git', ['-C', workDir, 'show', `HEAD:${relativePath}`], {
642
+ encoding: 'utf-8',
643
+ maxBuffer: 5 * 1024 * 1024,
644
+ stdio: ['ignore', 'pipe', 'ignore'],
645
+ }) || ''));
646
+ } catch {}
647
+
648
+ return contents.some(content => this._directScopeSlugsMatchingText(String(content || '').toLowerCase(), slugs).length > 0);
649
+ }
650
+
628
651
  _directTaskScopeAllowsPath(filePath, workDir, task) {
629
652
  const { slugs, pageOnly } = this._extractDirectExplicitScope(task);
630
653
  if (slugs.length === 0) return true;
@@ -635,6 +658,7 @@ export class OpenClawCLI extends EventEmitter {
635
658
  const lower = String(scopePath || '').toLowerCase();
636
659
  const slugAllowed = this._directScopeSlugsMatchingText(lower, slugs).length > 0;
637
660
  if (!slugAllowed) {
661
+ if (this._isDirectScopedReferenceSourcePath(filePath, workDir, scopePath, slugs, pageOnly, task)) return true;
638
662
  return this._isDirectNewPageOwnedAssetPath(filePath, workDir, scopePath, pageOnly);
639
663
  }
640
664
  return !pageOnly || this._isDirectPageSourcePath(scopePath);
@@ -875,9 +899,21 @@ export class OpenClawCLI extends EventEmitter {
875
899
  }
876
900
 
877
901
  _isDirectBroadUiQualityTask(task) {
902
+ if (this._allowsDirectScopedPageSourcesToRemainDeleted(task)) return false;
878
903
  return /\b(ui|ux|visual|design|frontend|css|html|pages?|dashboard|website|web app|app screen|layout|component|responsive|professional|polish(?:ed)?|vibecoded|vibe-coded|interface|screen|arena|competitive|competition|scoreboard|ranking|visual hierarchy|user experience|design system|readability|readable|legibility|contrast|accessibility)\b/i.test(String(task || ''));
879
904
  }
880
905
 
906
+ _allowsDirectScopedPageSourcesToRemainDeleted(task) {
907
+ const text = String(task || '');
908
+ const asksForDeletion = /\b(delete|remove|drop|unpublish|decommission|take down|take offline)\b/i.test(text);
909
+ if (!asksForDeletion) return false;
910
+
911
+ const rebuildNearDeletion =
912
+ /\b(delete|remove|drop|unpublish|decommission|take down|take offline)\b[^.!?\n]{0,180}\b(rebuild|recreate|remake|rewrite|replace|restore|redesign|build|create|implement|start over|from scratch|clean start|fresh start|same urls?|same paths?|preserv(?:e|ing) the same urls?)\b/i.test(text) ||
913
+ /\b(rebuild|recreate|remake|rewrite|replace|restore|redesign|build|create|implement|start over|from scratch|clean start|fresh start|same urls?|same paths?|preserv(?:e|ing) the same urls?)\b[^.!?\n]{0,180}\b(delete|remove|drop|unpublish|decommission|take down|take offline)\b/i.test(text);
914
+ return !rebuildNearDeletion;
915
+ }
916
+
881
917
  _gitHeadContentForPath(filePath) {
882
918
  try {
883
919
  const rawTargetPath = String(filePath || '');
@@ -1346,6 +1382,137 @@ export class OpenClawCLI extends EventEmitter {
1346
1382
  return '';
1347
1383
  }
1348
1384
 
1385
+ _directTaskForbidsGitDelivery(task) {
1386
+ return /\b(do not|don't|dont|without|skip|avoid)\b[^.!?\n]{0,80}\b(commit|push|publish|deploy|release)\b/i.test(String(task || '')) ||
1387
+ /\b(local only|leave uncommitted|do not ship|don't ship|dont ship)\b/i.test(String(task || ''));
1388
+ }
1389
+
1390
+ _directGitHasRemote(workDir) {
1391
+ try {
1392
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
1393
+ cwd: workDir,
1394
+ encoding: 'utf-8',
1395
+ stdio: ['ignore', 'pipe', 'ignore'],
1396
+ });
1397
+ const remote = String(execFileSync('git', ['remote'], {
1398
+ cwd: workDir,
1399
+ encoding: 'utf-8',
1400
+ stdio: ['ignore', 'pipe', 'ignore'],
1401
+ }) || '').trim();
1402
+ return remote.length > 0;
1403
+ } catch {
1404
+ return false;
1405
+ }
1406
+ }
1407
+
1408
+ _directGitPendingDeliveryMessage(workDir, task, { directMutationCount = 0, taskRequiresFileChange = false } = {}) {
1409
+ if (this._directTaskForbidsGitDelivery(task)) return '';
1410
+ if (!directMutationCount && !taskRequiresFileChange) return '';
1411
+ if (!this._directGitHasRemote(workDir)) return '';
1412
+
1413
+ const status = this._directGitStatusPorcelain(workDir).trim();
1414
+ if (!status) return '';
1415
+
1416
+ return [
1417
+ 'The repo still has uncommitted changes after the task work.',
1418
+ 'This is not complete delivery for a git-backed project with a remote.',
1419
+ 'Review git status, stage only the intended task changes, commit them with a clear message, push to the configured upstream, then verify the pushed/live state.',
1420
+ `Current git status:\n${status}`,
1421
+ ].join('\n');
1422
+ }
1423
+
1424
+ _directGitStatusPaths(statusText) {
1425
+ const paths = [];
1426
+ for (const rawLine of String(statusText || '').split('\n')) {
1427
+ if (!rawLine.trim()) continue;
1428
+ let rawPath = rawLine.length > 3 ? rawLine.slice(3).trim() : '';
1429
+ if (!rawPath) continue;
1430
+ if (rawPath.includes(' -> ')) rawPath = rawPath.split(' -> ').pop().trim();
1431
+ rawPath = rawPath.replace(/^"|"$/g, '');
1432
+ if (rawPath && !paths.includes(rawPath)) paths.push(rawPath);
1433
+ }
1434
+ return paths;
1435
+ }
1436
+
1437
+ _directGitAutoDeliveryEligible(workDir, task, statusText) {
1438
+ const { slugs } = this._extractDirectExplicitScope(task);
1439
+ if (slugs.length === 0) return { ok: false, reason: 'No explicit task scope was detected for automatic delivery.' };
1440
+ const paths = this._directGitStatusPaths(statusText);
1441
+ if (paths.length === 0) return { ok: false, reason: 'No git status paths to deliver.' };
1442
+ const outOfScope = paths.filter(rel => !this._directTaskScopeAllowsPath(path.resolve(workDir, rel), workDir, task));
1443
+ if (outOfScope.length > 0) {
1444
+ return { ok: false, reason: `Pending git changes include paths outside the resolved task scope: ${outOfScope.join(', ')}` };
1445
+ }
1446
+ return { ok: true, paths };
1447
+ }
1448
+
1449
+ _directCommitMessageForTask(task) {
1450
+ const cleaned = String(task || '')
1451
+ .replace(/\s+/g, ' ')
1452
+ .replace(/[.?!]+$/g, '')
1453
+ .trim();
1454
+ if (!cleaned) return 'Update project files';
1455
+ const message = cleaned.length > 86 ? cleaned.slice(0, 83).trimEnd() + '...' : cleaned;
1456
+ return message.charAt(0).toUpperCase() + message.slice(1);
1457
+ }
1458
+
1459
+ _directMaybeAutoDeliverGitChanges(workDir, task, { directMutationCount = 0, taskRequiresFileChange = false } = {}) {
1460
+ const pending = this._directGitPendingDeliveryMessage(workDir, task, { directMutationCount, taskRequiresFileChange });
1461
+ if (!pending) return { delivered: false, pending: '' };
1462
+
1463
+ const status = this._directGitStatusPorcelain(workDir);
1464
+ if (!status.trim()) return { delivered: false, pending: '' };
1465
+ const eligibility = this._directGitAutoDeliveryEligible(workDir, task, status);
1466
+ if (!eligibility.ok) return { delivered: false, pending, reason: eligibility.reason };
1467
+
1468
+ const message = this._directCommitMessageForTask(task);
1469
+ try {
1470
+ execFileSync('git', ['add', '--', ...eligibility.paths], {
1471
+ cwd: workDir,
1472
+ encoding: 'utf-8',
1473
+ stdio: ['ignore', 'pipe', 'pipe'],
1474
+ });
1475
+ const stagedDiff = execFileSync('git', ['diff', '--cached', '--stat'], {
1476
+ cwd: workDir,
1477
+ encoding: 'utf-8',
1478
+ stdio: ['ignore', 'pipe', 'pipe'],
1479
+ }).trim();
1480
+ if (!stagedDiff) return { delivered: false, pending: 'No staged changes remained after staging intended task paths.' };
1481
+
1482
+ execFileSync('git', ['commit', '-m', message], {
1483
+ cwd: workDir,
1484
+ encoding: 'utf-8',
1485
+ stdio: ['ignore', 'pipe', 'pipe'],
1486
+ });
1487
+ execFileSync('git', ['push'], {
1488
+ cwd: workDir,
1489
+ encoding: 'utf-8',
1490
+ stdio: ['ignore', 'pipe', 'pipe'],
1491
+ });
1492
+
1493
+ const head = String(execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
1494
+ cwd: workDir,
1495
+ encoding: 'utf-8',
1496
+ stdio: ['ignore', 'pipe', 'pipe'],
1497
+ }) || '').trim();
1498
+ const remaining = this._directGitStatusPorcelain(workDir).trim();
1499
+ return {
1500
+ delivered: true,
1501
+ message: [
1502
+ `Committed and pushed intended task changes${head ? ` at ${head}` : ''}.`,
1503
+ stagedDiff ? `Delivered diff:\n${stagedDiff}` : null,
1504
+ remaining ? `Remaining git status:\n${remaining}` : 'Working tree is clean after push.',
1505
+ ].filter(Boolean).join('\n'),
1506
+ };
1507
+ } catch (err) {
1508
+ return {
1509
+ delivered: false,
1510
+ pending,
1511
+ reason: `Automatic git delivery failed: ${err.stderr?.toString?.().trim() || err.message || err}`,
1512
+ };
1513
+ }
1514
+ }
1515
+
1349
1516
  /**
1350
1517
  * Resolve the actual JSONL file path for a task session.
1351
1518
  * The embedded runner uses embeddedSessionId = "task-{taskId}", so the file is
@@ -4183,9 +4350,11 @@ export class OpenClawCLI extends EventEmitter {
4183
4350
  'You are a helpful AI assistant running inside AgentForge. For implementation tasks, inspect the local project, make concrete file changes, test them, and then summarize. Use tools when the task requires executing commands, reading/writing files, or checking the browser. Do not stop with a plan when code changes are required.',
4184
4351
  'The AgentForge dashboard is the orchestration control surface. Do not inspect or use it as product context unless the user task is explicitly about AgentForge itself. For user-project work, inspect the target project files and the actual app/site URL or local preview page.',
4185
4352
  'For local browser verification, use the URL or port proven by project code, server output, or an already successful browser result. Do not try arbitrary localhost ports such as 3000 or 5000 just because they are common defaults.',
4353
+ 'For git-backed projects with a remote, do not finish with intended changes only on disk. Stage the final intended changes, commit them, push to the configured upstream, and verify the resulting state unless the user explicitly asked not to. Do not create confirmation/status files just to prove the task is done.',
4186
4354
  ].join('\n');
4187
- const taskRequiresFileChange = /\b(make|create|build|add|implement|update|edit|change|fix|improve|redesign|refactor|write|generate|scaffold|wire|ship|work on)\b/i.test(task);
4188
- const taskRequiresVisualVerification = /\b(ui|ux|visual|design|frontend|css|html|pages?|dashboard|website|web app|app screen|layout|component|responsive|professional|polish(?:ed)?|vibecoded|vibe-coded|interface|screen|arena|competitive|competition|scoreboard|ranking|visual hierarchy|user experience|design system|readability|readable|legibility|contrast|accessibility)\b/i.test(task);
4355
+ const taskRequiresFileChange = /\b(make|create|build|add|implement|update|edit|change|fix|improve|redesign|refactor|write|generate|scaffold|wire|ship|work on|delete|remove|drop|unpublish|decommission|take down|take offline)\b/i.test(task);
4356
+ const taskAllowsDeletedScopedPages = this._allowsDirectScopedPageSourcesToRemainDeleted(task);
4357
+ const taskRequiresVisualVerification = !taskAllowsDeletedScopedPages && /\b(ui|ux|visual|design|frontend|css|html|pages?|dashboard|website|web app|app screen|layout|component|responsive|professional|polish(?:ed)?|vibecoded|vibe-coded|interface|screen|arena|competitive|competition|scoreboard|ranking|visual hierarchy|user experience|design system|readability|readable|legibility|contrast|accessibility)\b/i.test(task);
4189
4358
  const directRequestTimeoutMs = Math.max(
4190
4359
  30_000,
4191
4360
  Number(process.env.AGENTFORGE_DIRECT_LLM_REQUEST_TIMEOUT_MS || (taskRequiresVisualVerification ? 120_000 : 90_000))
@@ -4699,12 +4868,13 @@ export class OpenClawCLI extends EventEmitter {
4699
4868
  return warnings.join('\n').slice(0, 1200);
4700
4869
  };
4701
4870
  const directStopResponse = (reason, forceIncomplete = false) => {
4702
- const noDeliverable = taskRequiresFileChange && directMutationCount === 0;
4871
+ const pendingGitDelivery = this._directGitPendingDeliveryMessage(workDir, task, { directMutationCount, taskRequiresFileChange });
4872
+ const noDeliverable = taskRequiresFileChange && directMutationCount === 0 && !pendingGitDelivery;
4703
4873
  const missingScopedMutation = missingScopedMutationSlugs();
4704
4874
  const missingVisualVerification = taskRequiresVisualVerification && directMutationCount > 0 && !hasCleanLocalVerificationForLatestMutation();
4705
4875
  const visualQualityBlocked = taskRequiresVisualVerification && directMutationCount > 0 && !!lastDirectVisualWarning;
4706
- const stoppedBeforeSummary = forceIncomplete && !noDeliverable && missingScopedMutation.length === 0 && !missingVisualVerification && !visualQualityBlocked;
4707
- const incomplete = forceIncomplete || noDeliverable || missingScopedMutation.length > 0 || missingVisualVerification || visualQualityBlocked;
4876
+ const stoppedBeforeSummary = forceIncomplete && !noDeliverable && missingScopedMutation.length === 0 && !missingVisualVerification && !visualQualityBlocked && !pendingGitDelivery;
4877
+ const incomplete = forceIncomplete || noDeliverable || missingScopedMutation.length > 0 || missingVisualVerification || visualQualityBlocked || !!pendingGitDelivery;
4708
4878
  return {
4709
4879
  text: [
4710
4880
  noDeliverable
@@ -4715,6 +4885,8 @@ export class OpenClawCLI extends EventEmitter {
4715
4885
  ? `Native AgentForge tools changed files, but the latest visual verification still reported visible UI problems. ${lastDirectVisualWarning} ${reason} This is not complete for a UI task. Fix the visible issues, reload the changed screen, and verify again.`
4716
4886
  : missingVisualVerification
4717
4887
  ? `Native AgentForge tools changed files, but the changed UI was not successfully loaded and inspected after the edits. ${missingScopedVerificationText()} ${reason} This is not complete for a UI task. Start or repair the local app server, open the real app URL, inspect the changed screen, fix visible issues, then finish. After a clean visual pass, do not make another file edit before delivery; if you edit again, verify again.`
4888
+ : pendingGitDelivery
4889
+ ? `Native AgentForge tools changed files, but the repo has not been delivered yet. ${reason}\n${pendingGitDelivery}`
4718
4890
  : stoppedBeforeSummary
4719
4891
  ? `Native AgentForge tools completed ${directToolCount} action(s), but the model stopped before producing a final summary. ${reason} This is not complete. Continue from the current files and browser state, verify the result, and finish with a clear final summary.`
4720
4892
  : `Native AgentForge tools completed ${directToolCount} action(s), including any edits or browser checks listed below. ${reason}`,
@@ -4783,6 +4955,20 @@ export class OpenClawCLI extends EventEmitter {
4783
4955
  usedTools: directToolCount > 0,
4784
4956
  };
4785
4957
  }
4958
+ const delivery = this._directMaybeAutoDeliverGitChanges(workDir, task, { directMutationCount, taskRequiresFileChange });
4959
+ if (delivery.delivered) {
4960
+ recordDirectToolSummary('git', delivery.message);
4961
+ } else if (delivery.pending) {
4962
+ return {
4963
+ text: [
4964
+ summary || fallbackSummary,
4965
+ delivery.pending,
4966
+ delivery.reason ? `Delivery blocker: ${delivery.reason}` : null,
4967
+ ].filter(Boolean).join('\n'),
4968
+ hasToolCalls: false,
4969
+ usedTools: directToolCount > 0,
4970
+ };
4971
+ }
4786
4972
  return {
4787
4973
  text: [
4788
4974
  summary || fallbackSummary,
@@ -4887,7 +5073,8 @@ export class OpenClawCLI extends EventEmitter {
4887
5073
  const text = parts.filter(p => p.text).map(p => p.text).join('');
4888
5074
  if (!functionCalls.length) {
4889
5075
  if (step > 0) {
4890
- if (taskRequiresFileChange && directMutationCount === 0 && directInternalNudgeCount < 2) {
5076
+ const pendingGitDelivery = this._directGitPendingDeliveryMessage(workDir, task, { directMutationCount, taskRequiresFileChange });
5077
+ if (taskRequiresFileChange && directMutationCount === 0 && !pendingGitDelivery && directInternalNudgeCount < 2) {
4891
5078
  directInternalNudgeCount += 1;
4892
5079
  contents.push(content);
4893
5080
  contents.push({
@@ -4902,7 +5089,7 @@ export class OpenClawCLI extends EventEmitter {
4902
5089
  });
4903
5090
  continue;
4904
5091
  }
4905
- if (taskRequiresFileChange && directMutationCount === 0) {
5092
+ if (taskRequiresFileChange && directMutationCount === 0 && !pendingGitDelivery) {
4906
5093
  const inspectedText = stripCompletionMarker(text);
4907
5094
  return {
4908
5095
  text: [
@@ -73,7 +73,8 @@ export const DEFAULT_TASK_GUIDES = Object.freeze({
73
73
  - Run the relevant checks or explain exactly why they could not run.
74
74
  - Do not create throwaway proof files such as verification notes, screenshots, or status artifacts inside the user's repo unless the user asked for them.
75
75
  - Do not create or switch git branches unless the user asked for a branch or pull request. If the user asks to commit and push, deliver on the current branch/upstream.
76
- - If the user asks to commit, push, publish, deploy, release, or update a live site, that delivery step is part of the task. Do not report completion until you have run the matching git/deploy/publish command, verified the target state, and reported exact evidence.
76
+ - If you intentionally change files in a git repo that has a configured remote/upstream, delivery includes staging the final verified changes, committing them with a clear message, pushing them, and verifying the target state after the push. Do not report completion while intended changes are only local unless the user explicitly asked not to push, the repo has no remote/upstream, authentication blocks the push, or verification/checks failed. Report those as blockers with exact evidence.
77
+ - If the user asks to commit, push, publish, deploy, release, or update a live site, that delivery step is explicitly part of the task. Do not report completion until you have run the matching git/deploy/publish command, verified the target state, and reported exact evidence.
77
78
  - Report changed files and remaining blockers only after implementation is complete.`,
78
79
  }),
79
80
  });
package/src/worker.js CHANGED
@@ -506,8 +506,10 @@ export class AgentForgeWorker extends EventEmitter {
506
506
  const asksForDeletion = /\b(delete|remove|drop|unpublish|decommission|take down|take offline)\b/i.test(text);
507
507
  if (!asksForDeletion) return false;
508
508
 
509
- const asksForRebuildOrRepair = /\b(rebuild|recreate|remake|rewrite|replace|restore|redesign|build|create|implement|fix|improve|polish|repair|start over|from scratch|clean start|fresh start|same urls?|same paths?|preserv(?:e|ing) the same urls?)\b/i.test(text);
510
- return !asksForRebuildOrRepair;
509
+ const rebuildNearDeletion =
510
+ /\b(delete|remove|drop|unpublish|decommission|take down|take offline)\b[^.!?\n]{0,180}\b(rebuild|recreate|remake|rewrite|replace|restore|redesign|build|create|implement|start over|from scratch|clean start|fresh start|same urls?|same paths?|preserv(?:e|ing) the same urls?)\b/i.test(text) ||
511
+ /\b(rebuild|recreate|remake|rewrite|replace|restore|redesign|build|create|implement|start over|from scratch|clean start|fresh start|same urls?|same paths?|preserv(?:e|ing) the same urls?)\b[^.!?\n]{0,180}\b(delete|remove|drop|unpublish|decommission|take down|take offline)\b/i.test(text);
512
+ return !rebuildNearDeletion;
511
513
  }
512
514
 
513
515
  _parseNumstat(output, source) {
@@ -701,10 +703,28 @@ export class AgentForgeWorker extends EventEmitter {
701
703
  return scopedSources.length > 0 && outOfScopeMentions.length === 0;
702
704
  }
703
705
 
704
- _scopeAllowsChangedPath(baseline, rel, allowedSlugs, pageOnly) {
706
+ _isScopedReferenceSourcePath(baseline, rel, allowedSlugs, pageOnly, userMessage) {
707
+ if (!baseline?.root || !pageOnly || !this._allowsScopedPageSourcesToRemainDeleted(userMessage)) return false;
708
+ if (!Array.isArray(allowedSlugs) || allowedSlugs.length === 0) return false;
709
+ if (!this._isPageSourcePath(rel)) return false;
710
+
711
+ let content = '';
712
+ try {
713
+ content = this._gitPathExistsAtRef(baseline.root, baseline.head || 'HEAD', rel)
714
+ ? this._gitOutput(baseline.root, ['show', `${baseline.head || 'HEAD'}:${rel}`], 1000000)
715
+ : readFileSync(path.join(baseline.root, rel), 'utf-8');
716
+ } catch {
717
+ return false;
718
+ }
719
+
720
+ return this._scopeSlugsMatchingText(String(content || '').toLowerCase(), allowedSlugs).length > 0;
721
+ }
722
+
723
+ _scopeAllowsChangedPath(baseline, rel, allowedSlugs, pageOnly, userMessage = '') {
705
724
  const lower = String(rel || '').toLowerCase();
706
725
  const slugAllowed = this._scopeSlugsMatchingText(lower, allowedSlugs).length > 0;
707
726
  if (slugAllowed) return !pageOnly || this._isPageSourcePath(rel);
727
+ if (this._isScopedReferenceSourcePath(baseline, rel, allowedSlugs, pageOnly, userMessage)) return true;
708
728
  return this._isNewScopedPageOwnedAsset(baseline, rel, allowedSlugs, pageOnly);
709
729
  }
710
730
 
@@ -734,7 +754,7 @@ export class AgentForgeWorker extends EventEmitter {
734
754
  const initialDirty = new Set(baseline.initialDirtyPaths || []);
735
755
  const outOfScope = [...names]
736
756
  .filter(rel => !initialDirty.has(rel))
737
- .filter(rel => !this._scopeAllowsChangedPath(baseline, rel, allowedSlugs, pageOnly))
757
+ .filter(rel => !this._scopeAllowsChangedPath(baseline, rel, allowedSlugs, pageOnly, userMessage))
738
758
  .sort();
739
759
  if (outOfScope.length > 0) {
740
760
  warnings.push({ repo: baseline.root, head: baseline.head, allowedSlugs, pageOnly, files: outOfScope });
@@ -800,7 +820,7 @@ export class AgentForgeWorker extends EventEmitter {
800
820
  const initialDirty = new Set(baseline.initialDirtyPaths || []);
801
821
  const files = [...names]
802
822
  .filter(rel => resetPreexistingScopedTargets || !initialDirty.has(rel))
803
- .filter(rel => this._scopeAllowsChangedPath(baseline, rel, allowedSlugs, pageOnly))
823
+ .filter(rel => this._scopeAllowsChangedPath(baseline, rel, allowedSlugs, pageOnly, userMessage))
804
824
  .sort();
805
825
  if (files.length === 0) continue;
806
826
 
@@ -966,6 +986,40 @@ export class AgentForgeWorker extends EventEmitter {
966
986
  return warnings.length > 0 ? this._formatDeliveryStateNudge(warnings) : '';
967
987
  }
968
988
 
989
+ _repoBaselinesHaveDeliveredScopedChanges(repoBaselines, userMessage) {
990
+ if (!Array.isArray(repoBaselines) || repoBaselines.length === 0) return false;
991
+ const { slugs: allowedSlugs, pageOnly } = this._extractExplicitScope(userMessage);
992
+
993
+ for (const baseline of repoBaselines) {
994
+ if (!baseline?.root || !baseline.head) continue;
995
+ if (this._gitStatusPorcelain(baseline.root, 10000).trim()) continue;
996
+
997
+ const head = this._gitOutput(baseline.root, ['rev-parse', 'HEAD']);
998
+ if (!head || head === baseline.head) continue;
999
+
1000
+ const currentBranch = this._gitOutput(baseline.root, ['rev-parse', '--abbrev-ref', 'HEAD']);
1001
+ if (baseline.branch && currentBranch && currentBranch !== baseline.branch && !this._allowsBranchDelivery(userMessage)) continue;
1002
+
1003
+ const upstream = this._gitOutput(baseline.root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
1004
+ if (!upstream) continue;
1005
+ const unpushed = Number.parseInt(this._gitOutput(baseline.root, ['rev-list', '--count', `${upstream}..HEAD`]) || '0', 10);
1006
+ if (!Number.isFinite(unpushed) || unpushed > 0) continue;
1007
+
1008
+ const changed = String(this._gitOutput(baseline.root, ['diff', '--name-only', `${baseline.head}..HEAD`], 10000) || '')
1009
+ .split('\n')
1010
+ .map(line => line.trim())
1011
+ .filter(Boolean);
1012
+ if (changed.length === 0) continue;
1013
+ if (allowedSlugs.length > 0) {
1014
+ const outOfScope = changed.filter(rel => !this._scopeAllowsChangedPath(baseline, rel, allowedSlugs, pageOnly, userMessage));
1015
+ if (outOfScope.length > 0) continue;
1016
+ }
1017
+ return true;
1018
+ }
1019
+
1020
+ return false;
1021
+ }
1022
+
969
1023
  _isBroadUiQualityTask(userMessage) {
970
1024
  const text = String(userMessage || '');
971
1025
  return /\b(professional|polish(?:ed)?|premium|quality|vibecoded|vibe-coded|looks? (?:bad|rough|unfinished|basic)|redesign|improve(?:d)? (?:the )?(?:ui|ux|design|page|pages|site|app|interface)|make (?:it|this|the .{0,50}) (?:look|feel)|landing pages?|listing pages?)\b/i.test(text);
@@ -2722,6 +2776,10 @@ export class AgentForgeWorker extends EventEmitter {
2722
2776
 
2723
2777
  const initialIsVisualUiTask = this._isVisualUiTask(userMessage, platformGuides);
2724
2778
  const initialIsPublishTask = this._isPublishTask(userMessage);
2779
+ const initialRequiresDeliverable =
2780
+ initialIsVisualUiTask ||
2781
+ initialIsPublishTask ||
2782
+ /\b(make|create|build|add|implement|update|edit|change|fix|improve|redesign|refactor|write|generate|scaffold|wire|ship|work on|delete|remove|drop|unpublish|decommission|take down|take offline)\b/i.test(String(userMessage || ''));
2725
2783
  const TASK_TIMEOUT_MS = this._taskTimeoutMs({
2726
2784
  message: userMessage,
2727
2785
  platformGuides,
@@ -3526,6 +3584,7 @@ export class AgentForgeWorker extends EventEmitter {
3526
3584
  let runtimeStallRetryCount = 0;
3527
3585
  let runtimeModelSwitchCount = 0;
3528
3586
  let uiVerificationRetryCount = 0;
3587
+ let taskAcceptedComplete = false;
3529
3588
  const runtimeModelsTried = new Set();
3530
3589
  const consumeUiRepairNudge = (reason, detail = '') => {
3531
3590
  if (!isVisualUiTask) return '';
@@ -4184,6 +4243,7 @@ export class AgentForgeWorker extends EventEmitter {
4184
4243
  iterations: iteration
4185
4244
  });
4186
4245
  }
4246
+ taskAcceptedComplete = true;
4187
4247
  break;
4188
4248
  }
4189
4249
 
@@ -4265,10 +4325,12 @@ export class AgentForgeWorker extends EventEmitter {
4265
4325
  cleanForCheck.match(/\?[^?]*$/m);
4266
4326
  if (isShortCompleteReply && !isVisualUiTask) {
4267
4327
  console.log(`[${taskId}] Agent gave short complete reply (${cleanForCheck.length} chars) — accepting as done (no nudge)`);
4328
+ taskAcceptedComplete = true;
4268
4329
  break;
4269
4330
  }
4270
4331
  if (isConversational && !isVisualUiTask) {
4271
4332
  console.log(`[${taskId}] Agent gave conversational reply — accepting as done (no nudge)`);
4333
+ taskAcceptedComplete = true;
4272
4334
  break;
4273
4335
  }
4274
4336
  const hasPublishEvidence = isPublishTask && this._hasPublishEvidence(output);
@@ -4415,6 +4477,7 @@ export class AgentForgeWorker extends EventEmitter {
4415
4477
  continue;
4416
4478
  }
4417
4479
  console.log(`[${taskId}] Publish evidence detected without explicit completion signal — accepting as done`);
4480
+ taskAcceptedComplete = true;
4418
4481
  break;
4419
4482
  }
4420
4483
  // After genuine tool activity, check if the final paragraph reads like a conclusion.
@@ -4437,11 +4500,13 @@ export class AgentForgeWorker extends EventEmitter {
4437
4500
  console.log(`[${taskId}] UI conclusion detected without visual verification — continuing`);
4438
4501
  } else {
4439
4502
  console.log(`[${taskId}] Conclusion detected after tool activity (${lastPara.length} chars) — accepting as done`);
4503
+ taskAcceptedComplete = true;
4440
4504
  break;
4441
4505
  }
4442
4506
  }
4443
4507
  }
4444
4508
  const madeRealProgress = hasSubstantiveProgress;
4509
+ const hasExplicitIncompleteResult = /\bThis is not complete\b|\bnot complete delivery\b|\brepo still has uncommitted changes\b|\bhas not been delivered yet\b|\bDelivery blocker\b/i.test(output);
4445
4510
  if (visualVerificationFailureNudge) {
4446
4511
  uiVerificationRetryCount++;
4447
4512
  if (uiVerificationRetryCount >= UI_REPAIR_NUDGE_LIMIT) {
@@ -4469,6 +4534,22 @@ export class AgentForgeWorker extends EventEmitter {
4469
4534
  nudgeCount = 0;
4470
4535
  console.log(`[${taskId}] Incomplete turn after tool call on iteration ${iteration} — resetting nudge count and retrying`);
4471
4536
  iterationMessage = withTaskContext(`The task is: "${userMessage}"\n\nYour last tool call hit a timeout and the response was cut short. Please retry the same tool call now. When finished, end your response with ✓ TASK_COMPLETE.`);
4537
+ } else if (hasExplicitIncompleteResult) {
4538
+ if (this._repoBaselinesHaveDeliveredScopedChanges(repoBaselines, scopeAwareUserMessage)) {
4539
+ console.log(`[${taskId}] Incomplete runner text ignored because scoped repo changes are already clean and pushed`);
4540
+ taskAcceptedComplete = true;
4541
+ taskResult.result = {
4542
+ ...(taskResult.result || {}),
4543
+ output: `Verified scoped changes are committed, pushed, and clean.\n✓ TASK_COMPLETE`,
4544
+ };
4545
+ break;
4546
+ }
4547
+ nudgeCount++;
4548
+ if (nudgeCount >= MAX_NUDGES) {
4549
+ throw new Error(`Task remained incomplete after repeated retries: ${output.trim().slice(0, 600)}`);
4550
+ }
4551
+ console.log(`[${taskId}] Incomplete task result detected — continuing (${nudgeCount}/${MAX_NUDGES})`);
4552
+ iterationMessage = withTaskContext(`The task is: "${userMessage}"\n\nAgentForge determined the task is not complete yet:\n\n${output.trim().slice(0, 1200)}\n\nContinue from the current state and finish the missing work or delivery. Only end with ✓ TASK_COMPLETE after the task is actually complete.`);
4472
4553
  } else if (madeRealProgress) {
4473
4554
  nudgeCount = 0;
4474
4555
  console.log(`[${taskId}] Agent made substantive progress on iteration ${iteration} — continuing without nudge`);
@@ -4478,7 +4559,11 @@ export class AgentForgeWorker extends EventEmitter {
4478
4559
  } else {
4479
4560
  nudgeCount++;
4480
4561
  if (nudgeCount >= MAX_NUDGES) {
4562
+ if (initialRequiresDeliverable) {
4563
+ throw new Error(`Agent did not produce a completion signal after ${nudgeCount} retries for a task that requires a deliverable.`);
4564
+ }
4481
4565
  console.log(`[${taskId}] Agent gave ${nudgeCount} consecutive narration-only responses — stopping`);
4566
+ taskAcceptedComplete = true;
4482
4567
  break;
4483
4568
  }
4484
4569
  // Agent only narrated without acting — nudge it to use tools.
@@ -4509,6 +4594,13 @@ export class AgentForgeWorker extends EventEmitter {
4509
4594
  }
4510
4595
  }
4511
4596
 
4597
+ if (initialRequiresDeliverable && taskResult?.success !== false && !taskAcceptedComplete) {
4598
+ const rawLoopOutput = taskResult?.result?.output || '';
4599
+ if (!/✓\s*TASK_COMPLETE/i.test(rawLoopOutput) && !this._repoBaselinesHaveDeliveredScopedChanges(repoBaselines, scopeAwareUserMessage)) {
4600
+ throw new Error(`Task ended without a verified completion signal for a deliverable task. Last output: ${String(rawLoopOutput).trim().slice(0, 600)}`);
4601
+ }
4602
+ }
4603
+
4512
4604
  // Clean up listeners and timer
4513
4605
  if (_cleanup) { _cleanup(); _cleanup = null; }
4514
4606
 
@@ -5476,6 +5568,9 @@ end tell`.trim().replace(/\n/g, '\n');
5476
5568
 
5477
5569
  _isVisualUiTask(message, platformGuides = []) {
5478
5570
  const text = String(message || '').toLowerCase();
5571
+ if (this._allowsScopedPageSourcesToRemainDeleted(text)) {
5572
+ return false;
5573
+ }
5479
5574
  if (Array.isArray(platformGuides) && platformGuides.some(g => String(g?.taskType || g?.task_type || '').toLowerCase() === 'ui')) {
5480
5575
  return true;
5481
5576
  }