@hamp10/agentforge 0.2.23 → 0.2.25
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 +1 -1
- package/scripts/check-task-semantics.js +125 -14
- package/src/OpenClawCLI.js +251 -44
- package/src/browser.js +45 -1
- package/src/preview-server.js +11 -1
- package/src/taskSemantics.js +37 -2
- package/src/worker.js +79 -15
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@ import { existsSync, lstatSync, mkdtempSync, mkdirSync, readFileSync, readlinkSy
|
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { deflateSync } from 'node:zlib';
|
|
9
|
-
import { extractExplicitScope } from '../src/taskSemantics.js';
|
|
9
|
+
import { extractExplicitScope, scopeSlugAliases, scopeSlugsMatchingText } from '../src/taskSemantics.js';
|
|
10
10
|
import { AgentForgeWorker } from '../src/worker.js';
|
|
11
11
|
import { OpenClawCLI } from '../src/OpenClawCLI.js';
|
|
12
12
|
|
|
@@ -23,6 +23,12 @@ const cases = [
|
|
|
23
23
|
absent: ['example-com', 'visual', 'quality'],
|
|
24
24
|
pageOnly: true,
|
|
25
25
|
},
|
|
26
|
+
{
|
|
27
|
+
text: 'Work on the Example.com listing pages for AlphaBoard.ai and BetaMatch.ai. Make them visually excellent and consistent with the rest of Example.com. Only change those two listing pages.',
|
|
28
|
+
slugs: ['alphaboard-ai', 'betamatch-ai'],
|
|
29
|
+
absent: ['example-com', 'visual', 'excellent'],
|
|
30
|
+
pageOnly: true,
|
|
31
|
+
},
|
|
26
32
|
{
|
|
27
33
|
text: 'Work on the Example.com listing pages for AlphaBoard and BetaMatch. Delete and rebuild those two listing page implementations from a clean start, preserving the same URLs and site conventions. Fix the readability and design issues. Only change those two listing pages.',
|
|
28
34
|
slugs: ['alphaboard', 'betamatch'],
|
|
@@ -85,6 +91,17 @@ for (const item of cases) {
|
|
|
85
91
|
}
|
|
86
92
|
}
|
|
87
93
|
|
|
94
|
+
assert.deepEqual(
|
|
95
|
+
scopeSlugAliases('AgentBoard.ai'),
|
|
96
|
+
['agentboard-ai', 'agentboard'],
|
|
97
|
+
'domain/page scope aliases should let page-owned assets omit the public suffix'
|
|
98
|
+
);
|
|
99
|
+
assert.deepEqual(
|
|
100
|
+
scopeSlugsMatchingText('public_html/css/agentboard.css', ['agentboard-ai']),
|
|
101
|
+
['agentboard-ai'],
|
|
102
|
+
'scoped ownership should match page-owned assets named after the product, not only exact domain slugs'
|
|
103
|
+
);
|
|
104
|
+
|
|
88
105
|
const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
89
106
|
|
|
90
107
|
const writeRgbPng = (filePath, width, height, sample) => {
|
|
@@ -382,9 +399,19 @@ try {
|
|
|
382
399
|
assert.match(
|
|
383
400
|
worker._formatScopeDriftNudge(worker._findScopeDriftRepoChanges(baseline, message)),
|
|
384
401
|
/domain-detail\.css/i,
|
|
385
|
-
'shared stylesheet files should
|
|
402
|
+
'new shared-looking stylesheet files should be reported as out-of-scope until scoped pages own/link them'
|
|
403
|
+
);
|
|
404
|
+
writeFileSync(
|
|
405
|
+
alphaFixturePath,
|
|
406
|
+
alphaFixtureCurrent.replace('</head>', '<link rel="stylesheet" href="../css/domain-detail.css">\n</head>')
|
|
407
|
+
);
|
|
408
|
+
assert.equal(
|
|
409
|
+
worker._findScopeDriftRepoChanges(baseline, message).some(w => w.files.includes('public_html/css/domain-detail.css')),
|
|
410
|
+
false,
|
|
411
|
+
'new stylesheet files linked only by scoped pages should be treated as page-owned scoped assets'
|
|
386
412
|
);
|
|
387
413
|
rmSync(sharedCssFile, { force: true });
|
|
414
|
+
writeFileSync(alphaFixturePath, alphaFixtureCurrent);
|
|
388
415
|
const directOldHtml = '<!doctype html><html><body><main><section class="hero"><h1>Alpha</h1><p>Existing body copy.</p></section></main></body></html>';
|
|
389
416
|
const directInlineStyleHtml = '<!doctype html><html><body><main><section class="hero" style="padding: 4rem; color: white;"><h1>Alpha</h1><p>Existing body copy.</p></section></main></body></html>';
|
|
390
417
|
assert.throws(
|
|
@@ -579,6 +606,14 @@ try {
|
|
|
579
606
|
),
|
|
580
607
|
'scoped shell touch should allow page-owned CSS files'
|
|
581
608
|
);
|
|
609
|
+
assert.doesNotThrow(
|
|
610
|
+
() => cli._guardDirectBashCommand(
|
|
611
|
+
'touch public_html/css/alpha.css',
|
|
612
|
+
fixture.repo,
|
|
613
|
+
{ task: 'Work on the Example.com listing pages for Alpha.ai and Beta.ai. Only change those listing pages and directly owned assets.' }
|
|
614
|
+
),
|
|
615
|
+
'scoped shell touch should allow page-owned CSS files that use the product name without the domain suffix'
|
|
616
|
+
);
|
|
582
617
|
assert.doesNotThrow(
|
|
583
618
|
() => cli._guardDirectBashCommand(
|
|
584
619
|
"sed -i '' 's/Existing/Updated/g' public_html/domains/alpha.html",
|
|
@@ -587,14 +622,23 @@ try {
|
|
|
587
622
|
),
|
|
588
623
|
'scoped shell sed should ignore the substitution script and allow canonical target page edits'
|
|
589
624
|
);
|
|
590
|
-
assert.
|
|
625
|
+
assert.doesNotThrow(
|
|
591
626
|
() => cli._guardDirectBashCommand(
|
|
592
627
|
'touch public_html/css/domain-detail.css',
|
|
593
628
|
fixture.repo,
|
|
594
629
|
{ task: message }
|
|
595
630
|
),
|
|
596
|
-
|
|
597
|
-
|
|
631
|
+
'direct scoped work should allow creating a new page-owned CSS asset before the target page links it'
|
|
632
|
+
);
|
|
633
|
+
const staleLinkedCssHtml = '<!doctype html><html><head><link rel="stylesheet" href="css/alpha.css"></head><body><main><section><h1>Alpha</h1><p>Fresh page body copy with rebuilt structure.</p></section></main></body></html>';
|
|
634
|
+
assert.throws(
|
|
635
|
+
() => cli._validateDirectUiFileContent(
|
|
636
|
+
path.join(fixture.repo, 'public_html', 'domains', 'alpha.html'),
|
|
637
|
+
staleLinkedCssHtml,
|
|
638
|
+
{ task: 'Delete and rebuild the listing pages for Alpha.ai and Beta.ai from a clean start. Only change those listing pages and directly owned assets.' }
|
|
639
|
+
),
|
|
640
|
+
/still links unchanged old page-owned stylesheet/i,
|
|
641
|
+
'clean-start HTML rebuilds should not keep linking unchanged old page-owned stylesheets'
|
|
598
642
|
);
|
|
599
643
|
const seamPng = path.join(fixture.repo, 'dominant-seam.png');
|
|
600
644
|
writeRgbPng(seamPng, 900, 600, (x, y) => (
|
|
@@ -604,8 +648,8 @@ try {
|
|
|
604
648
|
));
|
|
605
649
|
assert.match(
|
|
606
650
|
cli._detectScreenshotVisualDiscontinuities(seamPng).join('\n'),
|
|
607
|
-
/
|
|
608
|
-
'screenshot discontinuity scan should catch large
|
|
651
|
+
/screenshot continuity break/i,
|
|
652
|
+
'screenshot discontinuity scan should catch large rectangular visual discontinuities'
|
|
609
653
|
);
|
|
610
654
|
const subtlePartialSurfacePng = path.join(fixture.repo, 'subtle-partial-surface.png');
|
|
611
655
|
writeRgbPng(subtlePartialSurfacePng, 1000, 700, (x, y) => {
|
|
@@ -614,8 +658,8 @@ try {
|
|
|
614
658
|
});
|
|
615
659
|
assert.match(
|
|
616
660
|
cli._detectScreenshotVisualDiscontinuities(subtlePartialSurfacePng).join('\n'),
|
|
617
|
-
/partial-surface
|
|
618
|
-
'screenshot discontinuity scan should catch large subtle partial-surface
|
|
661
|
+
/partial-surface screenshot continuity break/i,
|
|
662
|
+
'screenshot discontinuity scan should catch large subtle partial-surface visual discontinuities'
|
|
619
663
|
);
|
|
620
664
|
} finally {
|
|
621
665
|
rmSync(fixture.repo, { recursive: true, force: true });
|
|
@@ -625,6 +669,8 @@ const openClawSource = readFileSync(new URL('../src/OpenClawCLI.js', import.meta
|
|
|
625
669
|
const workerSource = readFileSync(new URL('../src/worker.js', import.meta.url), 'utf-8');
|
|
626
670
|
const workerBinSource = readFileSync(new URL('../bin/agentforge.js', import.meta.url), 'utf-8');
|
|
627
671
|
const defaultGuidesSource = readFileSync(new URL('../src/default-task-guides.js', import.meta.url), 'utf-8');
|
|
672
|
+
const browserSource = readFileSync(new URL('../src/browser.js', import.meta.url), 'utf-8');
|
|
673
|
+
const previewServerSource = readFileSync(new URL('../src/preview-server.js', import.meta.url), 'utf-8');
|
|
628
674
|
const serverSource = readFileSync(new URL('../../../src/web-server-auth.js', import.meta.url), 'utf-8');
|
|
629
675
|
const dashboardSource = readFileSync(new URL('../../../public/dashboard.js', import.meta.url), 'utf-8');
|
|
630
676
|
assert.match(
|
|
@@ -644,13 +690,13 @@ assert.match(
|
|
|
644
690
|
);
|
|
645
691
|
assert.match(
|
|
646
692
|
openClawSource,
|
|
647
|
-
/
|
|
648
|
-
'screenshot discontinuity detection should fail large
|
|
693
|
+
/visible screenshot continuity break across a dominant UI surface/i,
|
|
694
|
+
'screenshot discontinuity detection should fail large visual continuity breaks'
|
|
649
695
|
);
|
|
650
696
|
assert.match(
|
|
651
697
|
openClawSource,
|
|
652
|
-
/partial-surface
|
|
653
|
-
'screenshot discontinuity detection should fail subtle partial-surface
|
|
698
|
+
/partial-surface screenshot continuity break/i,
|
|
699
|
+
'screenshot discontinuity detection should fail subtle partial-surface visual continuity breaks'
|
|
654
700
|
);
|
|
655
701
|
assert.match(
|
|
656
702
|
openClawSource,
|
|
@@ -822,6 +868,26 @@ assert.match(
|
|
|
822
868
|
/requiresComparableVisualContextForMutation/i,
|
|
823
869
|
'scoped UI work should require visual reference context when local peer pages can be opened'
|
|
824
870
|
);
|
|
871
|
+
assert.match(
|
|
872
|
+
openClawSource,
|
|
873
|
+
/comparableSourceContextCountForMutation[\s\S]*item\.kind === 'source'/i,
|
|
874
|
+
'scoped UI comparable context should require source/code inspection, not count browser-only views as source context'
|
|
875
|
+
);
|
|
876
|
+
assert.match(
|
|
877
|
+
openClawSource,
|
|
878
|
+
/localBrowserContextUrls[\s\S]*directReferenceInspections[\s\S]*kind === 'browser'[\s\S]*sourceCandidateUrlPaths/i,
|
|
879
|
+
'comparable visual context should use local browser reference URLs, not only already-opened target URLs'
|
|
880
|
+
);
|
|
881
|
+
assert.match(
|
|
882
|
+
openClawSource,
|
|
883
|
+
/if \(!invalidScopedLoad\) rememberLocalScopedUrl/i,
|
|
884
|
+
'target pages with design warnings should still seed local peer URL discovery for visual context'
|
|
885
|
+
);
|
|
886
|
+
assert.match(
|
|
887
|
+
openClawSource,
|
|
888
|
+
/public_html[\s\S]*rootMappedUrl/i,
|
|
889
|
+
'visual peer URL suggestions should map static roots like public_html to browser paths'
|
|
890
|
+
);
|
|
825
891
|
assert.match(
|
|
826
892
|
openClawSource,
|
|
827
893
|
/_directUiContextByTask/i,
|
|
@@ -844,7 +910,42 @@ assert.match(
|
|
|
844
910
|
);
|
|
845
911
|
assert.match(
|
|
846
912
|
openClawSource,
|
|
847
|
-
/
|
|
913
|
+
/Network\.setCacheDisabled[\s\S]*waitForRequestedDocument/i,
|
|
914
|
+
'direct browser verification should bypass stale local cache and wait for the requested page before judging UI'
|
|
915
|
+
);
|
|
916
|
+
assert.match(
|
|
917
|
+
openClawSource,
|
|
918
|
+
/__agentforge_verify/i,
|
|
919
|
+
'direct browser verification should cache-bust local verification URLs'
|
|
920
|
+
);
|
|
921
|
+
assert.match(
|
|
922
|
+
openClawSource,
|
|
923
|
+
/forgetLocalScopedUrl[\s\S]*rendered page content did not identify target|rendered page content did not identify target[\s\S]*forgetLocalScopedUrl/i,
|
|
924
|
+
'wrong-page scoped browser results should invalidate remembered local preview URLs'
|
|
925
|
+
);
|
|
926
|
+
assert.match(
|
|
927
|
+
browserSource,
|
|
928
|
+
/setCacheEnabled\(false\)[\s\S]*waitForPageReady/i,
|
|
929
|
+
'AgentForge browser tool should bypass stale localhost cache and wait for local navigation readiness'
|
|
930
|
+
);
|
|
931
|
+
assert.match(
|
|
932
|
+
browserSource,
|
|
933
|
+
/__agentforge_verify/i,
|
|
934
|
+
'AgentForge browser tool should cache-bust localhost navigations'
|
|
935
|
+
);
|
|
936
|
+
assert.match(
|
|
937
|
+
previewServerSource,
|
|
938
|
+
/extname\(urlPath\)[\s\S]*writeHead\(404[\s\S]*no-store/i,
|
|
939
|
+
'preview server should not silently SPA-fallback explicit missing asset/page requests'
|
|
940
|
+
);
|
|
941
|
+
assert.match(
|
|
942
|
+
previewServerSource,
|
|
943
|
+
/Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate'/,
|
|
944
|
+
'preview server responses should disable browser/proxy caching during visual QA'
|
|
945
|
+
);
|
|
946
|
+
assert.match(
|
|
947
|
+
openClawSource,
|
|
948
|
+
/Open at least one nearby peer page from the same app\/site in the browser and inspect it visually/i,
|
|
848
949
|
'comparable UI context guidance should require visual inspection, not source-only matching'
|
|
849
950
|
);
|
|
850
951
|
assert.doesNotMatch(
|
|
@@ -857,6 +958,16 @@ assert.match(
|
|
|
857
958
|
/const minRatio = isLargeText \? 3 : 4\.5;/,
|
|
858
959
|
'control/link contrast checks should use text-size-aware WCAG thresholds'
|
|
859
960
|
);
|
|
961
|
+
assert.match(
|
|
962
|
+
dashboardSource,
|
|
963
|
+
/typingEl\.dataset\.startedAt\s*=\s*startedAt/,
|
|
964
|
+
'live guide insertion should preserve the active feed start time for chronological reload ordering'
|
|
965
|
+
);
|
|
966
|
+
assert.match(
|
|
967
|
+
dashboardSource,
|
|
968
|
+
/time:\s*startedAt/,
|
|
969
|
+
'completed live feeds should save with their start time so guide bubbles appear after the feed they interrupted'
|
|
970
|
+
);
|
|
860
971
|
assert.match(
|
|
861
972
|
defaultGuidesSource,
|
|
862
973
|
/anything that reads as rendering damage rather than deliberate design/i,
|
package/src/OpenClawCLI.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
extractExplicitScope,
|
|
12
12
|
extractNamedPageScopeSlugs,
|
|
13
13
|
hasExplicitScopeRestriction,
|
|
14
|
+
scopeSlugsMatchingText,
|
|
14
15
|
scopeSlug,
|
|
15
16
|
} from './taskSemantics.js';
|
|
16
17
|
import treeKill from 'tree-kill';
|
|
@@ -588,6 +589,10 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
588
589
|
return scopeSlug(value);
|
|
589
590
|
}
|
|
590
591
|
|
|
592
|
+
_directScopeSlugsMatchingText(value, slugs) {
|
|
593
|
+
return scopeSlugsMatchingText(value, slugs);
|
|
594
|
+
}
|
|
595
|
+
|
|
591
596
|
_addDirectScopeCandidate(candidates, value) {
|
|
592
597
|
return addScopeCandidate(candidates, value);
|
|
593
598
|
}
|
|
@@ -604,10 +609,22 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
604
609
|
return /\.(html?|jsx?|tsx?|vue|svelte|astro|mdx?|css|scss|sass|less)$/i.test(String(relativePath || ''));
|
|
605
610
|
}
|
|
606
611
|
|
|
612
|
+
_isDirectPageAssetPath(relativePath) {
|
|
613
|
+
return /\.(css|scss|sass|less|jsx?|tsx?)$/i.test(String(relativePath || ''));
|
|
614
|
+
}
|
|
615
|
+
|
|
607
616
|
_isDirectHtmlPagePath(relativePath) {
|
|
608
617
|
return /\.(?:html?|xhtml|astro|mdx?)$/i.test(String(relativePath || ''));
|
|
609
618
|
}
|
|
610
619
|
|
|
620
|
+
_isDirectNewPageOwnedAssetPath(filePath, workDir, relativePath, pageOnly) {
|
|
621
|
+
if (!pageOnly || !this._isDirectPageAssetPath(relativePath)) return false;
|
|
622
|
+
if (existsSync(filePath)) return false;
|
|
623
|
+
const relative = path.relative(workDir, filePath);
|
|
624
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return false;
|
|
625
|
+
return true;
|
|
626
|
+
}
|
|
627
|
+
|
|
611
628
|
_directTaskScopeAllowsPath(filePath, workDir, task) {
|
|
612
629
|
const { slugs, pageOnly } = this._extractDirectExplicitScope(task);
|
|
613
630
|
if (slugs.length === 0) return true;
|
|
@@ -616,8 +633,10 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
616
633
|
? filePath
|
|
617
634
|
: relative;
|
|
618
635
|
const lower = String(scopePath || '').toLowerCase();
|
|
619
|
-
const slugAllowed =
|
|
620
|
-
if (!slugAllowed)
|
|
636
|
+
const slugAllowed = this._directScopeSlugsMatchingText(lower, slugs).length > 0;
|
|
637
|
+
if (!slugAllowed) {
|
|
638
|
+
return this._isDirectNewPageOwnedAssetPath(filePath, workDir, scopePath, pageOnly);
|
|
639
|
+
}
|
|
621
640
|
return !pageOnly || this._isDirectPageSourcePath(scopePath);
|
|
622
641
|
}
|
|
623
642
|
|
|
@@ -645,7 +664,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
645
664
|
if (!entry.isFile() || !this._isDirectPageSourcePath(entry.name)) continue;
|
|
646
665
|
const rel = path.relative(workDir, fullPath) || fullPath;
|
|
647
666
|
const lower = rel.toLowerCase();
|
|
648
|
-
if (
|
|
667
|
+
if (this._directScopeSlugsMatchingText(lower, slugs).length > 0 && !results.includes(rel)) results.push(rel);
|
|
649
668
|
}
|
|
650
669
|
};
|
|
651
670
|
walk(workDir, 0);
|
|
@@ -670,7 +689,8 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
670
689
|
`Requested path: ${relative || filePath}`,
|
|
671
690
|
`Allowed scope tokens: ${slugs.join(', ')}${pageOnly ? ' (page/source files only)' : ''}.`,
|
|
672
691
|
candidateText.trim(),
|
|
673
|
-
'Use a target file inside the requested scope, or
|
|
692
|
+
'Use a target file inside the requested scope, a page-local <style> block, or a new page-owned CSS/JS asset that is linked only from the requested target pages.',
|
|
693
|
+
'Do not edit shared/global files such as universal, base, reset, layout, or component styles unless the user explicitly asks for shared-site changes.',
|
|
674
694
|
].filter(Boolean).join(' ');
|
|
675
695
|
}
|
|
676
696
|
|
|
@@ -806,7 +826,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
806
826
|
throw this._directScopeViolationError(this._formatDirectScopeError(filePath, workDir, options.task));
|
|
807
827
|
}
|
|
808
828
|
if (pageOnly && slugs.length > 0 && this._isDirectHtmlPagePath(relativePath)) {
|
|
809
|
-
const matchedSlugs =
|
|
829
|
+
const matchedSlugs = this._directScopeSlugsMatchingText(lowerRelativePath, slugs);
|
|
810
830
|
const isTrackedTarget = this._gitTracksExactPath(workDir, relativePath);
|
|
811
831
|
if (matchedSlugs.length > 0 && !existsSync(filePath) && !isTrackedTarget) {
|
|
812
832
|
const existingPages = this._directScopeFileCandidates(workDir, matchedSlugs, 20)
|
|
@@ -924,7 +944,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
924
944
|
const { slugs, pageOnly } = this._extractDirectExplicitScope(options?.task);
|
|
925
945
|
if (!pageOnly || slugs.length === 0) return;
|
|
926
946
|
const lowerPath = String(filePath || '').toLowerCase();
|
|
927
|
-
const matchingSlugs =
|
|
947
|
+
const matchingSlugs = this._directScopeSlugsMatchingText(lowerPath, slugs);
|
|
928
948
|
if (matchingSlugs.length === 0) return;
|
|
929
949
|
|
|
930
950
|
const root = this._gitRootForPath(filePath);
|
|
@@ -942,7 +962,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
942
962
|
const targetPageFiles = trackedFiles.filter(rel => {
|
|
943
963
|
const lower = rel.toLowerCase();
|
|
944
964
|
if (!/\.(?:html?|xhtml|jsx?|tsx?|vue|svelte|astro|mdx?)$/i.test(lower)) return false;
|
|
945
|
-
return matchingSlugs.
|
|
965
|
+
return scopeSlugsMatchingText(lower, matchingSlugs).length > 0;
|
|
946
966
|
});
|
|
947
967
|
if (targetPageFiles.length === 0) return;
|
|
948
968
|
|
|
@@ -1011,10 +1031,77 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
1011
1031
|
}
|
|
1012
1032
|
}
|
|
1013
1033
|
|
|
1034
|
+
_validateDirectCleanStartHtmlDoesNotReuseOldPageStylesheet(filePath, content, options = {}) {
|
|
1035
|
+
if (!this._isDirectBroadUiQualityTask(options?.task)) return;
|
|
1036
|
+
if (!/\.(?:html?|xhtml|astro|mdx?)$/i.test(String(filePath || ''))) return;
|
|
1037
|
+
const taskText = String(options?.task || '');
|
|
1038
|
+
const isCleanStartTask = /\b(?:delete|rebuild|clean start|from scratch|start over|fresh|remake)\b/i.test(taskText);
|
|
1039
|
+
if (!isCleanStartTask) return;
|
|
1040
|
+
const { slugs, pageOnly } = this._extractDirectExplicitScope(options?.task);
|
|
1041
|
+
if (!pageOnly || slugs.length === 0) return;
|
|
1042
|
+
|
|
1043
|
+
const matchingSlugs = this._directScopeSlugsMatchingText(filePath, slugs);
|
|
1044
|
+
if (matchingSlugs.length === 0) return;
|
|
1045
|
+
|
|
1046
|
+
const root = this._gitRootForPath(filePath);
|
|
1047
|
+
if (!root) return;
|
|
1048
|
+
|
|
1049
|
+
let pageDir = path.dirname(filePath);
|
|
1050
|
+
try {
|
|
1051
|
+
pageDir = path.dirname(realpathSync(filePath));
|
|
1052
|
+
} catch {}
|
|
1053
|
+
|
|
1054
|
+
const staleLinks = [];
|
|
1055
|
+
const html = String(content || '');
|
|
1056
|
+
for (const tagMatch of html.matchAll(/<link\b[^>]*>/gi)) {
|
|
1057
|
+
const tag = tagMatch[0] || '';
|
|
1058
|
+
if (!/\brel\s*=\s*["'][^"']*\bstylesheet\b[^"']*["']/i.test(tag)) continue;
|
|
1059
|
+
const href = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1] || '';
|
|
1060
|
+
if (!href || /^(?:https?:|data:|blob:|\/\/)/i.test(href)) continue;
|
|
1061
|
+
const cleanHref = href.split(/[?#]/)[0];
|
|
1062
|
+
if (!cleanHref || !/\.css$/i.test(cleanHref)) continue;
|
|
1063
|
+
const absolute = path.resolve(pageDir, cleanHref);
|
|
1064
|
+
const rel = path.relative(root, absolute).replace(/\\/g, '/');
|
|
1065
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
|
|
1066
|
+
if (this._directScopeSlugsMatchingText(rel, matchingSlugs).length === 0) continue;
|
|
1067
|
+
|
|
1068
|
+
let headCss = null;
|
|
1069
|
+
try {
|
|
1070
|
+
headCss = String(execFileSync('git', ['-C', root, 'show', `HEAD:${rel}`], {
|
|
1071
|
+
encoding: 'utf-8',
|
|
1072
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
1073
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1074
|
+
}) || '');
|
|
1075
|
+
} catch {
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
if (!existsSync(absolute)) continue;
|
|
1079
|
+
let currentCss = '';
|
|
1080
|
+
try {
|
|
1081
|
+
currentCss = readFileSync(absolute, 'utf-8');
|
|
1082
|
+
} catch {
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
if (this._cssComparableLines(headCss).join('\n') === this._cssComparableLines(currentCss).join('\n')) {
|
|
1086
|
+
staleLinks.push(rel);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (staleLinks.length === 0) return;
|
|
1091
|
+
const err = new Error([
|
|
1092
|
+
'Rejected clean-start page rebuild because the new HTML still links unchanged old page-owned stylesheet(s).',
|
|
1093
|
+
`Stale stylesheet(s): ${[...new Set(staleLinks)].slice(0, 4).join(', ')}.`,
|
|
1094
|
+
'For clean-start scoped page work, delete or materially rewrite old page-owned stylesheets, or move the design into a fresh page-local style block before linking the rebuilt page.',
|
|
1095
|
+
].join(' '));
|
|
1096
|
+
err.code = 'AGENTFORGE_UI_STALE_PAGE_STYLESHEET';
|
|
1097
|
+
throw err;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1014
1100
|
_validateDirectUiImplementationArtifacts(filePath, content, options = {}) {
|
|
1015
1101
|
if (!this._isDirectBroadUiQualityTask(options?.task)) return;
|
|
1016
1102
|
if (!/\.(?:html?|xhtml|css|s[ac]ss|jsx?|tsx?|vue|svelte|astro|mdx?)$/i.test(String(filePath || ''))) return;
|
|
1017
1103
|
this._validateDirectPageOwnedStylesheetIsFresh(filePath, content, options);
|
|
1104
|
+
this._validateDirectCleanStartHtmlDoesNotReuseOldPageStylesheet(filePath, content, options);
|
|
1018
1105
|
|
|
1019
1106
|
const oldText = String(options?.oldContent || '');
|
|
1020
1107
|
const headText = this._gitHeadContentForPath(filePath);
|
|
@@ -1220,7 +1307,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
1220
1307
|
.filter(line => {
|
|
1221
1308
|
if (slugs.length === 0) return true;
|
|
1222
1309
|
const lower = line.toLowerCase();
|
|
1223
|
-
return slugs.
|
|
1310
|
+
return scopeSlugsMatchingText(lower, slugs).length > 0;
|
|
1224
1311
|
});
|
|
1225
1312
|
if (scopedLines.length === 0) return '';
|
|
1226
1313
|
|
|
@@ -2662,6 +2749,29 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
2662
2749
|
return String(value || '').replace(/\/$/, '');
|
|
2663
2750
|
}
|
|
2664
2751
|
};
|
|
2752
|
+
const stripAgentForgeCacheBust = (value) => {
|
|
2753
|
+
try {
|
|
2754
|
+
const parsed = new URL(value);
|
|
2755
|
+
parsed.searchParams.delete('__agentforge_verify');
|
|
2756
|
+
return parsed.href.replace(/\/$/, '');
|
|
2757
|
+
} catch {
|
|
2758
|
+
return String(value || '').replace(/([?&])__agentforge_verify=[^&#]+&?/, '$1').replace(/[?&]$/, '').replace(/\/$/, '');
|
|
2759
|
+
}
|
|
2760
|
+
};
|
|
2761
|
+
const normalizeForNavigationCompare = (value) => stripAgentForgeCacheBust(normalize(value));
|
|
2762
|
+
const isLocalHttpUrl = (value) =>
|
|
2763
|
+
/^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i.test(String(value || ''));
|
|
2764
|
+
const withVerificationCacheBust = (value) => {
|
|
2765
|
+
if (!value || !isLocalHttpUrl(value)) return value;
|
|
2766
|
+
try {
|
|
2767
|
+
const parsed = new URL(value);
|
|
2768
|
+
parsed.searchParams.set('__agentforge_verify', `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
2769
|
+
return parsed.href;
|
|
2770
|
+
} catch {
|
|
2771
|
+
const sep = String(value).includes('?') ? '&' : '?';
|
|
2772
|
+
return `${value}${sep}__agentforge_verify=${Date.now()}`;
|
|
2773
|
+
}
|
|
2774
|
+
};
|
|
2665
2775
|
|
|
2666
2776
|
const listResp = await fetch(`${cdpBase}/json/list`, { signal: AbortSignal.timeout(5_000) });
|
|
2667
2777
|
if (!listResp.ok) throw new Error(`CDP list failed: HTTP ${listResp.status}`);
|
|
@@ -2674,6 +2784,9 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
2674
2784
|
/^https?:\/\/agentforgeai-production\.up\.railway\.app\/dashboard(?:[/?#]|$)/i.test(String(value || ''));
|
|
2675
2785
|
const rememberedUrl = this._directBrowserLastUrlByAgent.get(agentId) || null;
|
|
2676
2786
|
const effectiveRequestedUrl = requestedUrl || rememberedUrl || null;
|
|
2787
|
+
const navigationRequestedUrl = options?.afterMutation
|
|
2788
|
+
? withVerificationCacheBust(effectiveRequestedUrl)
|
|
2789
|
+
: effectiveRequestedUrl;
|
|
2677
2790
|
const usablePages = !taskIsAboutAgentForge
|
|
2678
2791
|
? pages.filter(p => !isAgentForgeControlSurface(p.url))
|
|
2679
2792
|
: pages;
|
|
@@ -2690,7 +2803,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
2690
2803
|
}
|
|
2691
2804
|
|
|
2692
2805
|
if (!page) {
|
|
2693
|
-
const newUrl = effectiveRequestedUrl || 'about:blank';
|
|
2806
|
+
const newUrl = navigationRequestedUrl || effectiveRequestedUrl || 'about:blank';
|
|
2694
2807
|
const newResp = await fetch(`${cdpBase}/json/new?${encodeURIComponent(newUrl)}`, {
|
|
2695
2808
|
method: 'PUT',
|
|
2696
2809
|
signal: AbortSignal.timeout(5_000),
|
|
@@ -2749,12 +2862,42 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
2749
2862
|
try {
|
|
2750
2863
|
await cdp('Page.enable');
|
|
2751
2864
|
await cdp('Runtime.enable');
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2865
|
+
await cdp('Network.enable').catch(() => null);
|
|
2866
|
+
await cdp('Network.setCacheDisabled', { cacheDisabled: true }).catch(() => null);
|
|
2867
|
+
const waitForRequestedDocument = async (expectedUrl) => {
|
|
2868
|
+
const expectedComparable = expectedUrl ? normalizeForNavigationCompare(expectedUrl) : '';
|
|
2869
|
+
let lastState = null;
|
|
2870
|
+
for (let i = 0; i < 45; i++) {
|
|
2871
|
+
try {
|
|
2872
|
+
const stateResult = await cdp('Runtime.evaluate', {
|
|
2873
|
+
expression: `(() => ({ href: location.href, readyState: document.readyState, bodyLength: document.body ? document.body.innerText.length : 0 }))()`,
|
|
2874
|
+
awaitPromise: true,
|
|
2875
|
+
returnByValue: true,
|
|
2876
|
+
});
|
|
2877
|
+
lastState = stateResult?.result?.value || null;
|
|
2878
|
+
const currentComparable = normalizeForNavigationCompare(lastState?.href || '');
|
|
2879
|
+
const urlReady = !expectedComparable || currentComparable === expectedComparable;
|
|
2880
|
+
const documentReady = lastState?.readyState && lastState.readyState !== 'loading';
|
|
2881
|
+
if (urlReady && documentReady) return lastState;
|
|
2882
|
+
} catch {
|
|
2883
|
+
// The old page context can disappear while the new document commits.
|
|
2884
|
+
}
|
|
2885
|
+
await new Promise(r => setTimeout(r, 200));
|
|
2886
|
+
}
|
|
2887
|
+
if (options?.afterMutation && expectedComparable && lastState?.href) {
|
|
2888
|
+
const currentComparable = normalizeForNavigationCompare(lastState.href);
|
|
2889
|
+
if (currentComparable !== expectedComparable) {
|
|
2890
|
+
throw new Error(`Browser failed to load requested verification URL ${stripAgentForgeCacheBust(expectedUrl)}; current tab is ${stripAgentForgeCacheBust(lastState.href)}`);
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
return lastState;
|
|
2894
|
+
};
|
|
2895
|
+
if (effectiveRequestedUrl && normalizeForNavigationCompare(page.url) !== normalizeForNavigationCompare(effectiveRequestedUrl)) {
|
|
2896
|
+
await cdp('Page.navigate', { url: navigationRequestedUrl || effectiveRequestedUrl });
|
|
2897
|
+
await waitForRequestedDocument(effectiveRequestedUrl);
|
|
2755
2898
|
} else if (effectiveRequestedUrl) {
|
|
2756
2899
|
await cdp('Page.reload', { ignoreCache: true });
|
|
2757
|
-
await
|
|
2900
|
+
await waitForRequestedDocument(effectiveRequestedUrl);
|
|
2758
2901
|
}
|
|
2759
2902
|
if (effectiveRequestedUrl) {
|
|
2760
2903
|
await cdp('Runtime.evaluate', {
|
|
@@ -2985,7 +3128,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
2985
3128
|
horizontal.y < height * 0.94;
|
|
2986
3129
|
if (!awayFromViewportEdge) continue;
|
|
2987
3130
|
warnings.push([
|
|
2988
|
-
`
|
|
3131
|
+
`visible screenshot continuity break across a dominant UI surface near x=${vertical.x}, y=${horizontal.y}`,
|
|
2989
3132
|
`(vertical span y=${vertical.minY}-${vertical.maxY}, horizontal span x=${horizontal.minX}-${horizontal.maxX})`,
|
|
2990
3133
|
].join(' '));
|
|
2991
3134
|
break;
|
|
@@ -3001,7 +3144,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
3001
3144
|
edge.average >= 38
|
|
3002
3145
|
);
|
|
3003
3146
|
if (dominantVertical) {
|
|
3004
|
-
warnings.push(`
|
|
3147
|
+
warnings.push(`visible vertical screenshot continuity break across a dominant UI surface near x=${dominantVertical.x} spanning y=${dominantVertical.minY}-${dominantVertical.maxY}`);
|
|
3005
3148
|
}
|
|
3006
3149
|
}
|
|
3007
3150
|
|
|
@@ -3054,7 +3197,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
3054
3197
|
}
|
|
3055
3198
|
|
|
3056
3199
|
if (partialHorizontal) {
|
|
3057
|
-
warnings.push(`
|
|
3200
|
+
warnings.push(`visible partial-surface screenshot continuity break near y=${partialHorizontal.y}, running from x=${partialHorizontal.start} to x=${partialHorizontal.end}`);
|
|
3058
3201
|
}
|
|
3059
3202
|
}
|
|
3060
3203
|
|
|
@@ -3106,7 +3249,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
3106
3249
|
}
|
|
3107
3250
|
|
|
3108
3251
|
if (partialVertical) {
|
|
3109
|
-
warnings.push(`
|
|
3252
|
+
warnings.push(`visible partial-surface screenshot continuity break near x=${partialVertical.x}, running from y=${partialVertical.start} to y=${partialVertical.end}`);
|
|
3110
3253
|
}
|
|
3111
3254
|
}
|
|
3112
3255
|
return warnings;
|
|
@@ -3554,7 +3697,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
3554
3697
|
try { return new URL(summaryUrl || requestedUrl || 'http://invalid.local/').pathname || ''; } catch { return ''; }
|
|
3555
3698
|
})();
|
|
3556
3699
|
const isAssetBrowserTarget = /\.(?:png|jpe?g|gif|webp|svg|ico|css|js|mjs|json|map|pdf|txt|xml|woff2?|ttf|otf)(?:$|[?#])/i.test(browserPathText);
|
|
3557
|
-
const matchedScopedSlugs =
|
|
3700
|
+
const matchedScopedSlugs = scopeSlugsMatchingText(browserScopeUrlText, explicitScopeSlugs);
|
|
3558
3701
|
const isScopedBrowserTarget =
|
|
3559
3702
|
explicitScopeSlugs.length === 0 ||
|
|
3560
3703
|
matchedScopedSlugs.length > 0;
|
|
@@ -3707,7 +3850,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
3707
3850
|
? `${visualWarningPrefix}: ${summary.brokenImages.length} visible image(s) failed to load: ${summary.brokenImages.map(img => img.alt || img.src || '(unlabeled image)').join(', ')}. ${visualWarningTail}`
|
|
3708
3851
|
: '';
|
|
3709
3852
|
const screenshotDiscontinuityWarning = screenshotDiscontinuityWarnings.length > 0
|
|
3710
|
-
? `${visualWarningPrefix}: screenshot
|
|
3853
|
+
? `${visualWarningPrefix}: screenshot pixel scan flagged visible continuity issue(s): ${screenshotDiscontinuityWarnings.slice(0, 2).join('; ')}. Inspect the screenshot and source to identify the actual rendering cause, then rework the affected layout/background/layering so the changed screen reads as intentional. Verify the page again. ${visualWarningTail}`
|
|
3711
3854
|
: '';
|
|
3712
3855
|
const reportLowContrastControls = options?.afterMutation && isScopedBrowserTarget && explicitScopeSlugs.length > 0
|
|
3713
3856
|
? (summary.lowContrastControls || []).filter(c => !c.chrome)
|
|
@@ -4091,8 +4234,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
4091
4234
|
return resolveDirectToolPath(args?.path || args?.file_path);
|
|
4092
4235
|
};
|
|
4093
4236
|
const scopeSlugsInText = (value) => {
|
|
4094
|
-
|
|
4095
|
-
return explicitScopeSlugsForTask.filter(slug => text.includes(slug));
|
|
4237
|
+
return scopeSlugsMatchingText(value, explicitScopeSlugsForTask);
|
|
4096
4238
|
};
|
|
4097
4239
|
const uiSourcePathInfo = (inputPath) => {
|
|
4098
4240
|
const candidate = String(inputPath || '').trim();
|
|
@@ -4160,20 +4302,20 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
4160
4302
|
const candidates = comparableSiblingSourceCandidates(fileWriteTarget);
|
|
4161
4303
|
return candidates.length >= 2 ? 2 : 1;
|
|
4162
4304
|
};
|
|
4163
|
-
const
|
|
4305
|
+
const comparableSourceContextCountForMutation = (fileWriteTarget = null) => {
|
|
4164
4306
|
rememberTargetUiSource(fileWriteTarget);
|
|
4165
4307
|
const dirsWithSiblings = [...directTargetUiSourceDirs].filter(hasComparableSiblingSource);
|
|
4166
4308
|
if (dirsWithSiblings.length > 0) {
|
|
4167
4309
|
const matching = directReferenceInspections.filter(item => {
|
|
4168
4310
|
if (item.kind === 'source' && dirsWithSiblings.includes(item.dir)) return true;
|
|
4169
|
-
if (item.kind === 'browser' && item.urlPathDir) {
|
|
4170
|
-
return dirsWithSiblings.some(dir => dir.replace(/\\/g, '/').endsWith(item.urlPathDir));
|
|
4171
|
-
}
|
|
4172
4311
|
return false;
|
|
4173
4312
|
});
|
|
4174
|
-
return new Set(matching.map(item =>
|
|
4313
|
+
return new Set(matching.map(item => item.value)).size;
|
|
4175
4314
|
}
|
|
4176
|
-
return new Set(directReferenceInspections.
|
|
4315
|
+
return new Set(directReferenceInspections.filter(item => item.kind === 'source').map(item => item.value)).size;
|
|
4316
|
+
};
|
|
4317
|
+
const comparableUiContextCountForMutation = (fileWriteTarget = null) => {
|
|
4318
|
+
return comparableSourceContextCountForMutation(fileWriteTarget);
|
|
4177
4319
|
};
|
|
4178
4320
|
const comparableVisualContextCountForMutation = (fileWriteTarget = null) => {
|
|
4179
4321
|
rememberTargetUiSource(fileWriteTarget);
|
|
@@ -4185,11 +4327,39 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
4185
4327
|
});
|
|
4186
4328
|
return new Set(matching.map(item => item.value)).size;
|
|
4187
4329
|
};
|
|
4330
|
+
const localBrowserContextUrls = () => {
|
|
4331
|
+
const urls = [...directLocalScopedUrls];
|
|
4332
|
+
for (const item of directReferenceInspections) {
|
|
4333
|
+
if (item.kind === 'browser' && isLocalUiUrl(item.value) && !urls.includes(item.value)) {
|
|
4334
|
+
urls.push(item.value);
|
|
4335
|
+
}
|
|
4336
|
+
}
|
|
4337
|
+
return urls;
|
|
4338
|
+
};
|
|
4339
|
+
const sourceCandidateUrlPaths = (candidate) => {
|
|
4340
|
+
const rel = String(candidate || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
4341
|
+
const paths = [];
|
|
4342
|
+
const add = (value) => {
|
|
4343
|
+
const normalized = `/${String(value || '').replace(/^\/+/, '')}`;
|
|
4344
|
+
if (!paths.includes(normalized)) paths.push(normalized);
|
|
4345
|
+
};
|
|
4346
|
+
for (const root of ['public_html', 'public', 'static', 'dist', 'build']) {
|
|
4347
|
+
if (rel === root || rel.startsWith(`${root}/`)) {
|
|
4348
|
+
add(rel.slice(root.length).replace(/^\/+/, ''));
|
|
4349
|
+
}
|
|
4350
|
+
const marker = `/${root}/`;
|
|
4351
|
+
const idx = rel.indexOf(marker);
|
|
4352
|
+
if (idx >= 0) add(rel.slice(idx + marker.length));
|
|
4353
|
+
}
|
|
4354
|
+
add(path.basename(rel));
|
|
4355
|
+
return paths;
|
|
4356
|
+
};
|
|
4188
4357
|
const comparableBrowserUrlCandidates = (fileWriteTarget = null) => {
|
|
4189
4358
|
const sourceCandidates = comparableSiblingSourceCandidates(fileWriteTarget);
|
|
4190
|
-
|
|
4359
|
+
const baseUrls = localBrowserContextUrls();
|
|
4360
|
+
if (!sourceCandidates.length || baseUrls.length === 0) return [];
|
|
4191
4361
|
const urls = [];
|
|
4192
|
-
for (const knownUrl of [...
|
|
4362
|
+
for (const knownUrl of [...baseUrls].reverse()) {
|
|
4193
4363
|
let parsed = null;
|
|
4194
4364
|
try { parsed = new URL(knownUrl); } catch { parsed = null; }
|
|
4195
4365
|
if (!parsed) continue;
|
|
@@ -4197,18 +4367,27 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
4197
4367
|
const basePath = slash >= 0 ? parsed.pathname.slice(0, slash + 1) : '/';
|
|
4198
4368
|
for (const candidate of sourceCandidates) {
|
|
4199
4369
|
parsed.pathname = `${basePath}${path.basename(candidate)}`;
|
|
4200
|
-
|
|
4201
|
-
|
|
4370
|
+
parsed.search = '';
|
|
4371
|
+
parsed.hash = '';
|
|
4372
|
+
const sameDirUrl = parsed.href;
|
|
4373
|
+
if (!urls.includes(sameDirUrl)) urls.push(sameDirUrl);
|
|
4374
|
+
for (const urlPath of sourceCandidateUrlPaths(candidate)) {
|
|
4375
|
+
parsed.pathname = urlPath;
|
|
4376
|
+
parsed.search = '';
|
|
4377
|
+
parsed.hash = '';
|
|
4378
|
+
const rootMappedUrl = parsed.href;
|
|
4379
|
+
if (!urls.includes(rootMappedUrl)) urls.push(rootMappedUrl);
|
|
4380
|
+
}
|
|
4202
4381
|
if (urls.length >= 4) return urls;
|
|
4203
4382
|
}
|
|
4204
4383
|
}
|
|
4205
4384
|
return urls;
|
|
4206
4385
|
};
|
|
4207
4386
|
const requiresComparableVisualContextForMutation = (fileWriteTarget = null) => {
|
|
4208
|
-
return
|
|
4387
|
+
return localBrowserContextUrls().length > 0 && comparableSiblingSourceCandidates(fileWriteTarget).length > 0;
|
|
4209
4388
|
};
|
|
4210
4389
|
const hasComparableUiContextForMutation = (fileWriteTarget = null) => {
|
|
4211
|
-
const hasSourceContext =
|
|
4390
|
+
const hasSourceContext = comparableSourceContextCountForMutation(fileWriteTarget) >= requiredComparableUiContextCount(fileWriteTarget);
|
|
4212
4391
|
if (!hasSourceContext) return false;
|
|
4213
4392
|
if (!requiresComparableVisualContextForMutation(fileWriteTarget)) return true;
|
|
4214
4393
|
return comparableVisualContextCountForMutation(fileWriteTarget) >= 1;
|
|
@@ -4246,17 +4425,18 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
4246
4425
|
const missingComparableUiContextMessage = (fileWriteTarget = null) => {
|
|
4247
4426
|
const siblingCandidates = comparableSiblingSourceCandidates(fileWriteTarget);
|
|
4248
4427
|
const requiredCount = requiredComparableUiContextCount(fileWriteTarget);
|
|
4249
|
-
const
|
|
4428
|
+
const currentSourceCount = comparableSourceContextCountForMutation(fileWriteTarget);
|
|
4429
|
+
const currentVisualCount = comparableVisualContextCountForMutation(fileWriteTarget);
|
|
4250
4430
|
const candidateText = siblingCandidates.length > 0
|
|
4251
4431
|
? ` Read ${requiredCount === 1 ? 'one nearby peer source file' : 'at least two nearby peer source files'} first, for example: ${siblingCandidates.join(', ')}.`
|
|
4252
4432
|
: '';
|
|
4253
4433
|
const browserCandidates = comparableBrowserUrlCandidates(fileWriteTarget);
|
|
4254
4434
|
const needsVisualContext = requiresComparableVisualContextForMutation(fileWriteTarget);
|
|
4255
4435
|
const visualContextText = needsVisualContext
|
|
4256
|
-
? `
|
|
4436
|
+
? ` Open at least one nearby peer page from the same app/site in the browser and inspect it visually before editing. Current comparable visual context: ${currentVisualCount}/1.${browserCandidates.length > 0 ? ` Example local peer URL(s): ${browserCandidates.join(', ')}.` : ''}`
|
|
4257
4437
|
: '';
|
|
4258
4438
|
return [
|
|
4259
|
-
`Before editing scoped UI pages, inspect ${requiredCount === 1 ? 'at least one comparable local UI surface' : 'at least two comparable local UI surfaces'} from this project. Current comparable
|
|
4439
|
+
`Before editing scoped UI pages, inspect ${requiredCount === 1 ? 'at least one comparable local UI surface' : 'at least two comparable local UI surfaces'} from this project. Current comparable source context: ${currentSourceCount}/${requiredCount}.`,
|
|
4260
4440
|
'When comparable sibling pages/components exist near the target source, use one of those as read-only design-system context.',
|
|
4261
4441
|
'Do not count the site homepage, index/listing pages, header/footer partials, or shared/global CSS as the required page-level comparable context.',
|
|
4262
4442
|
'Shared/global CSS can supplement peer context, but it is not enough by itself for page-level design work.',
|
|
@@ -4304,7 +4484,15 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
4304
4484
|
};
|
|
4305
4485
|
const browserResultUrl = (result) => {
|
|
4306
4486
|
const match = String(result || '').match(/^URL:\s*(.+)$/mi);
|
|
4307
|
-
|
|
4487
|
+
const url = match?.[1]?.trim() || '';
|
|
4488
|
+
if (!url) return '';
|
|
4489
|
+
try {
|
|
4490
|
+
const parsed = new URL(url);
|
|
4491
|
+
parsed.searchParams.delete('__agentforge_verify');
|
|
4492
|
+
return parsed.href;
|
|
4493
|
+
} catch {
|
|
4494
|
+
return url.replace(/([?&])__agentforge_verify=[^&#]+&?/, '$1').replace(/[?&]$/, '');
|
|
4495
|
+
}
|
|
4308
4496
|
};
|
|
4309
4497
|
const isLocalUiUrl = (url) =>
|
|
4310
4498
|
/^(?:https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)|file:)/i.test(String(url || ''));
|
|
@@ -4326,6 +4514,15 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
4326
4514
|
if (directLocalScopedUrls.length > 8) directLocalScopedUrls.shift();
|
|
4327
4515
|
persistDirectUiContext();
|
|
4328
4516
|
};
|
|
4517
|
+
const forgetLocalScopedUrl = (slug, url = '') => {
|
|
4518
|
+
if (slug && directLocalUrlsByScope.get(slug) === url) directLocalUrlsByScope.delete(slug);
|
|
4519
|
+
if (url) {
|
|
4520
|
+
for (let i = directLocalScopedUrls.length - 1; i >= 0; i--) {
|
|
4521
|
+
if (directLocalScopedUrls[i] === url) directLocalScopedUrls.splice(i, 1);
|
|
4522
|
+
}
|
|
4523
|
+
}
|
|
4524
|
+
persistDirectUiContext();
|
|
4525
|
+
};
|
|
4329
4526
|
const missingScopedVerificationSlugs = () =>
|
|
4330
4527
|
[...directScopeLastMutationCounts.keys()]
|
|
4331
4528
|
.filter(slug => (directScopeLastVerificationCounts.get(slug) || 0) < (directScopeLastMutationCounts.get(slug) || 0));
|
|
@@ -4425,6 +4622,9 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
4425
4622
|
directBrowserUsed = true;
|
|
4426
4623
|
const visualWarning = extractVisualWarning(result);
|
|
4427
4624
|
if (visualWarning) {
|
|
4625
|
+
if (/rendered page content did not identify target|local server returned a fallback\/index page|Browser failed to load/i.test(visualWarning)) {
|
|
4626
|
+
forgetLocalScopedUrl(slug, url);
|
|
4627
|
+
}
|
|
4428
4628
|
visualWarnings.push(`${slug}: ${visualWarning}`);
|
|
4429
4629
|
recordDirectToolSummary('browser', result);
|
|
4430
4630
|
continue;
|
|
@@ -4450,7 +4650,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
4450
4650
|
const urlText = match?.[1]?.trim() || '';
|
|
4451
4651
|
if (!urlText) return false;
|
|
4452
4652
|
const { slugs } = this._extractDirectExplicitScope(task);
|
|
4453
|
-
if (slugs.length > 0 &&
|
|
4653
|
+
if (slugs.length > 0 && scopeSlugsMatchingText(urlText, slugs).length === 0) return false;
|
|
4454
4654
|
return /^(?:https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)|file:)/i.test(urlText);
|
|
4455
4655
|
};
|
|
4456
4656
|
const extractVisualWarning = (result) => {
|
|
@@ -4809,7 +5009,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
4809
5009
|
const message = missingComparableUiContextMessage(fileWriteTarget);
|
|
4810
5010
|
directComparableContextBlockCount += 1;
|
|
4811
5011
|
comparableContextBlockThisBatch = message;
|
|
4812
|
-
emitDirectThought('AgentForge blocked
|
|
5012
|
+
emitDirectThought('AgentForge blocked a scoped UI edit because comparable project UI context has not been inspected yet.');
|
|
4813
5013
|
this.emit('tool_activity', {
|
|
4814
5014
|
agentId,
|
|
4815
5015
|
event: 'tool_error',
|
|
@@ -4884,7 +5084,14 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
4884
5084
|
if (name === 'browser') {
|
|
4885
5085
|
directBrowserUsed = true;
|
|
4886
5086
|
const visualWarning = extractVisualWarning(result);
|
|
4887
|
-
|
|
5087
|
+
const resultUrl = browserResultUrl(result);
|
|
5088
|
+
const invalidScopedLoad = /rendered page content did not identify target|local server returned a fallback\/index page|Browser failed to load/i.test(visualWarning || '');
|
|
5089
|
+
if (!invalidScopedLoad) rememberLocalScopedUrl(resultUrl || args?.url || '');
|
|
5090
|
+
else {
|
|
5091
|
+
for (const slug of scopeSlugsInText(`${resultUrl}\n${args?.url || ''}`)) {
|
|
5092
|
+
forgetLocalScopedUrl(slug, resultUrl || args?.url || '');
|
|
5093
|
+
}
|
|
5094
|
+
}
|
|
4888
5095
|
if (directMutationCount > 0) {
|
|
4889
5096
|
if (visualWarning) {
|
|
4890
5097
|
lastDirectVisualWarning = visualWarning;
|
|
@@ -4931,7 +5138,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
4931
5138
|
text: [
|
|
4932
5139
|
'The previous write was rejected because it was outside the requested task scope.',
|
|
4933
5140
|
scopeViolationThisBatch,
|
|
4934
|
-
'Do not retry shared, global, or reference files. Implement the required UI changes inside the named target files or their directly owned assets only. If styling is needed, use page-local styles in those target files.',
|
|
5141
|
+
'Do not retry shared, global, or reference files. Implement the required UI changes inside the named target files or their directly owned assets only. If styling is needed, use page-local styles in those target files or page-owned scoped stylesheets whose names clearly match the target page.',
|
|
4935
5142
|
].join(' '),
|
|
4936
5143
|
}],
|
|
4937
5144
|
});
|
|
@@ -4940,9 +5147,9 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
4940
5147
|
if (comparableContextBlockThisBatch) {
|
|
4941
5148
|
if (directComparableContextBlockCount >= 3) {
|
|
4942
5149
|
return directStopResponse([
|
|
4943
|
-
'The model repeatedly tried to edit scoped UI before inspecting
|
|
5150
|
+
'The model repeatedly tried to edit scoped UI before inspecting non-target peer UI context.',
|
|
4944
5151
|
comparableContextBlockThisBatch,
|
|
4945
|
-
'This is not complete. Continue in a fresh iteration by reading
|
|
5152
|
+
'This is not complete. Continue in a fresh iteration by reading non-target sibling page/component source and opening a nearby peer page in the browser when a local preview is available, then edit only the requested target files.',
|
|
4946
5153
|
].join(' '), true);
|
|
4947
5154
|
}
|
|
4948
5155
|
contents.push({
|
|
@@ -4951,7 +5158,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
4951
5158
|
text: [
|
|
4952
5159
|
'The previous edit was rejected because comparable project UI context has not been inspected yet.',
|
|
4953
5160
|
comparableContextBlockThisBatch,
|
|
4954
|
-
'Reading another requested target page does not satisfy this requirement. Read
|
|
5161
|
+
'Reading another requested target page does not satisfy this requirement. Read non-target sibling page/component source from the candidate list and, when a local peer URL is provided, open that peer page in the browser before editing only the requested target files.',
|
|
4955
5162
|
].join(' '),
|
|
4956
5163
|
}],
|
|
4957
5164
|
});
|
package/src/browser.js
CHANGED
|
@@ -54,6 +54,47 @@ async function getPage(agentId, preferUrl) {
|
|
|
54
54
|
|
|
55
55
|
const _wait = (ms) => new Promise(r => setTimeout(r, ms));
|
|
56
56
|
|
|
57
|
+
function isLocalHttpUrl(value) {
|
|
58
|
+
return /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i.test(String(value || ''));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function withLocalCacheBust(value) {
|
|
62
|
+
if (!isLocalHttpUrl(value)) return value;
|
|
63
|
+
try {
|
|
64
|
+
const parsed = new URL(value);
|
|
65
|
+
parsed.searchParams.set('__agentforge_verify', `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
66
|
+
return parsed.href;
|
|
67
|
+
} catch {
|
|
68
|
+
const sep = String(value).includes('?') ? '&' : '?';
|
|
69
|
+
return `${value}${sep}__agentforge_verify=${Date.now()}`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function waitForPageReady(page, expectedUrl) {
|
|
74
|
+
const stripCacheBust = (value) => {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = new URL(value);
|
|
77
|
+
parsed.searchParams.delete('__agentforge_verify');
|
|
78
|
+
return parsed.href.replace(/\/$/, '');
|
|
79
|
+
} catch {
|
|
80
|
+
return String(value || '').replace(/([?&])__agentforge_verify=[^&#]+&?/, '$1').replace(/[?&]$/, '').replace(/\/$/, '');
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const expected = expectedUrl ? stripCacheBust(expectedUrl) : '';
|
|
84
|
+
for (let i = 0; i < 45; i++) {
|
|
85
|
+
try {
|
|
86
|
+
const state = await page.evaluate(() => ({
|
|
87
|
+
href: location.href,
|
|
88
|
+
readyState: document.readyState,
|
|
89
|
+
}));
|
|
90
|
+
if ((!expected || stripCacheBust(state.href) === expected) && state.readyState !== 'loading') return;
|
|
91
|
+
} catch {
|
|
92
|
+
// Page contexts can disappear while navigation commits.
|
|
93
|
+
}
|
|
94
|
+
await _wait(200);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
57
98
|
// Take a structured snapshot of a page — content + interactive elements + all inputs
|
|
58
99
|
async function _snapshot(page, agentId) {
|
|
59
100
|
const url = page.url();
|
|
@@ -160,9 +201,11 @@ async function _browserActionInner(input, agentId, browser) {
|
|
|
160
201
|
case 'open': {
|
|
161
202
|
const url = input.url || input.targetUrl;
|
|
162
203
|
const page = await getPage(agentId);
|
|
204
|
+
await page.setCacheEnabled(false).catch(() => null);
|
|
205
|
+
const navigationUrl = withLocalCacheBust(url);
|
|
163
206
|
// Use Promise.race to enforce a hard 20s cap — page.goto timeout option can hang
|
|
164
207
|
const navResult = await Promise.race([
|
|
165
|
-
page.goto(
|
|
208
|
+
page.goto(navigationUrl, { waitUntil: 'domcontentloaded', timeout: 20000 }),
|
|
166
209
|
new Promise((_, rej) => setTimeout(() => rej(new Error(`Navigation hard-timeout (20s): ${url}`)), 20000)),
|
|
167
210
|
]).catch(err => ({ __navError: err.message }));
|
|
168
211
|
if (navResult && navResult.__navError) {
|
|
@@ -171,6 +214,7 @@ async function _browserActionInner(input, agentId, browser) {
|
|
|
171
214
|
}
|
|
172
215
|
_agentPages.set(agentId, page);
|
|
173
216
|
// Brief pause for initial render, then snapshot
|
|
217
|
+
await waitForPageReady(page, url);
|
|
174
218
|
await _wait(400);
|
|
175
219
|
return await _snapshot(page, agentId);
|
|
176
220
|
}
|
package/src/preview-server.js
CHANGED
|
@@ -225,6 +225,11 @@ const server = createServer(async (req, res) => {
|
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
if (!existsSync(filePath)) {
|
|
228
|
+
if (extname(urlPath)) {
|
|
229
|
+
res.writeHead(404, { 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate' });
|
|
230
|
+
res.end('Not found');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
228
233
|
// SPA fallback
|
|
229
234
|
const fallback = join(serveDir, 'index.html');
|
|
230
235
|
if (existsSync(fallback)) filePath = fallback;
|
|
@@ -245,7 +250,12 @@ const server = createServer(async (req, res) => {
|
|
|
245
250
|
content = html;
|
|
246
251
|
}
|
|
247
252
|
|
|
248
|
-
res.writeHead(200, {
|
|
253
|
+
res.writeHead(200, {
|
|
254
|
+
'Content-Type': mime,
|
|
255
|
+
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
|
256
|
+
'Pragma': 'no-cache',
|
|
257
|
+
'Expires': '0',
|
|
258
|
+
});
|
|
249
259
|
res.end(content);
|
|
250
260
|
} catch (e) {
|
|
251
261
|
res.writeHead(500); res.end('Server error');
|
package/src/taskSemantics.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
const PAGE_KIND_RE = /\b(?:pages?|screens?|routes?|views?|listings?)\b/i;
|
|
2
2
|
const PAGE_KIND_WORD_RE = /^(?:listing|listings|page|pages|screen|screens|route|routes|view|views|site|website|app)$/i;
|
|
3
3
|
const ACTION_WORD_RE = /^(?:add|build|change|create|edit|fix|implement|improve|make|redesign|update|work)$/i;
|
|
4
|
+
const COMMON_DOMAIN_SUFFIXES = new Set([
|
|
5
|
+
'ai', 'app', 'co', 'com', 'dev', 'gg', 'io', 'net', 'org', 'so', 'xyz',
|
|
6
|
+
]);
|
|
4
7
|
|
|
5
8
|
const GENERIC_SCOPE_WORDS = new Set([
|
|
6
9
|
'a', 'an', 'the', 'and', 'or', 'page', 'pages', 'screen', 'screens',
|
|
@@ -28,6 +31,31 @@ export function scopeSlug(value) {
|
|
|
28
31
|
.replace(/^-+|-+$/g, '');
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
export function scopeSlugAliases(value) {
|
|
35
|
+
const slug = scopeSlug(value);
|
|
36
|
+
if (!slug) return [];
|
|
37
|
+
|
|
38
|
+
const aliases = new Set([slug]);
|
|
39
|
+
const parts = slug.split('-').filter(Boolean);
|
|
40
|
+
const suffix = parts.at(-1);
|
|
41
|
+
if (parts.length > 1 && COMMON_DOMAIN_SUFFIXES.has(suffix)) {
|
|
42
|
+
const withoutSuffix = parts.slice(0, -1).join('-');
|
|
43
|
+
if (withoutSuffix.length >= 3 && !GENERIC_SCOPE_WORDS.has(withoutSuffix)) {
|
|
44
|
+
aliases.add(withoutSuffix);
|
|
45
|
+
const compact = withoutSuffix.replace(/-/g, '');
|
|
46
|
+
if (compact.length >= 4 && !GENERIC_SCOPE_WORDS.has(compact)) aliases.add(compact);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return [...aliases];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function scopeSlugsMatchingText(value, slugs) {
|
|
54
|
+
const text = String(value || '').toLowerCase();
|
|
55
|
+
return (Array.isArray(slugs) ? slugs : [])
|
|
56
|
+
.filter(slug => scopeSlugAliases(slug).some(alias => alias && text.includes(alias)));
|
|
57
|
+
}
|
|
58
|
+
|
|
31
59
|
export function scopeExtractionText(message) {
|
|
32
60
|
const raw = String(message || '');
|
|
33
61
|
const taskMatches = [...raw.matchAll(/\b(?:The task is|Continue with the task):\s*"([^"]{1,4000})"/gi)];
|
|
@@ -116,11 +144,18 @@ export function extractExplicitScopeSlugs(message) {
|
|
|
116
144
|
}
|
|
117
145
|
|
|
118
146
|
const candidates = new Set();
|
|
147
|
+
const namedPageScopeSlugs = extractNamedPageScopeSlugs(text);
|
|
148
|
+
const pageContainerSlugs = new Set();
|
|
119
149
|
for (const match of text.matchAll(/\b[a-z0-9]+(?:[.-][a-z0-9]+)+\b/gi)) {
|
|
120
|
-
if (followedByNamedPageTargets(text, match))
|
|
150
|
+
if (followedByNamedPageTargets(text, match)) {
|
|
151
|
+
const slug = scopeSlug(match[0]);
|
|
152
|
+
if (!namedPageScopeSlugs.includes(slug)) pageContainerSlugs.add(slug);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (pageContainerSlugs.has(scopeSlug(match[0]))) continue;
|
|
121
156
|
addScopeCandidate(candidates, match[0]);
|
|
122
157
|
}
|
|
123
|
-
for (const slug of
|
|
158
|
+
for (const slug of namedPageScopeSlugs) {
|
|
124
159
|
candidates.add(slug);
|
|
125
160
|
}
|
|
126
161
|
return [...candidates];
|
package/src/worker.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
extractExplicitScopeSlugs,
|
|
10
10
|
extractNamedPageScopeSlugs,
|
|
11
11
|
hasExplicitScopeRestriction,
|
|
12
|
+
scopeSlugsMatchingText,
|
|
12
13
|
scopeSlug,
|
|
13
14
|
} from './taskSemantics.js';
|
|
14
15
|
|
|
@@ -606,6 +607,10 @@ export class AgentForgeWorker extends EventEmitter {
|
|
|
606
607
|
return scopeSlug(value);
|
|
607
608
|
}
|
|
608
609
|
|
|
610
|
+
_scopeSlugsMatchingText(value, slugs) {
|
|
611
|
+
return scopeSlugsMatchingText(value, slugs);
|
|
612
|
+
}
|
|
613
|
+
|
|
609
614
|
_addScopeCandidate(candidates, value) {
|
|
610
615
|
return addScopeCandidate(candidates, value);
|
|
611
616
|
}
|
|
@@ -626,6 +631,74 @@ export class AgentForgeWorker extends EventEmitter {
|
|
|
626
631
|
return /\.(html?|jsx?|tsx?|vue|svelte|astro|mdx?|css|scss|sass|less)$/i.test(String(relativePath || ''));
|
|
627
632
|
}
|
|
628
633
|
|
|
634
|
+
_isPageAssetPath(relativePath) {
|
|
635
|
+
return /\.(css|scss|sass|less|jsx?|tsx?)$/i.test(String(relativePath || ''));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
_fileMentionsAsset(content, sourceRel, assetRel) {
|
|
639
|
+
const source = String(content || '');
|
|
640
|
+
const assetUnix = String(assetRel || '').replace(/\\/g, '/');
|
|
641
|
+
const assetBase = path.basename(assetUnix);
|
|
642
|
+
const relativeFromSource = path.relative(path.dirname(sourceRel || ''), assetRel || '').replace(/\\/g, '/');
|
|
643
|
+
const candidates = [
|
|
644
|
+
assetBase,
|
|
645
|
+
assetUnix,
|
|
646
|
+
`/${assetUnix}`,
|
|
647
|
+
relativeFromSource,
|
|
648
|
+
relativeFromSource && !relativeFromSource.startsWith('.') ? `./${relativeFromSource}` : '',
|
|
649
|
+
].filter(Boolean);
|
|
650
|
+
return candidates.some(candidate => source.includes(candidate));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
_allCurrentPageSourceFiles(repo) {
|
|
654
|
+
const names = new Set();
|
|
655
|
+
const add = (output) => {
|
|
656
|
+
for (const rel of String(output || '').split('\n').map(line => line.trim()).filter(Boolean)) {
|
|
657
|
+
if (this._isPageSourcePath(rel)) names.add(rel);
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
add(this._gitOutput(repo, ['ls-files'], 30000));
|
|
661
|
+
add(this._gitOutput(repo, ['ls-files', '--others', '--exclude-standard'], 30000));
|
|
662
|
+
add(this._parseGitStatusPaths(this._gitStatusPorcelain(repo, 10000)).join('\n'));
|
|
663
|
+
return [...names].sort();
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
_isNewScopedPageOwnedAsset(baseline, rel, allowedSlugs, pageOnly) {
|
|
667
|
+
if (!baseline?.root || !pageOnly || !Array.isArray(allowedSlugs) || allowedSlugs.length === 0) return false;
|
|
668
|
+
if (!this._isPageAssetPath(rel)) return false;
|
|
669
|
+
if (this._scopeSlugsMatchingText(String(rel || '').toLowerCase(), allowedSlugs).length > 0) return true;
|
|
670
|
+
if (this._gitPathExistsAtRef(baseline.root, baseline.head || 'HEAD', rel)) return false;
|
|
671
|
+
|
|
672
|
+
const sourceFiles = this._allCurrentPageSourceFiles(baseline.root).filter(file => file !== rel);
|
|
673
|
+
const scopedSources = [];
|
|
674
|
+
const outOfScopeMentions = [];
|
|
675
|
+
|
|
676
|
+
for (const sourceRel of sourceFiles) {
|
|
677
|
+
const fullPath = path.join(baseline.root, sourceRel);
|
|
678
|
+
let content = '';
|
|
679
|
+
try {
|
|
680
|
+
content = readFileSync(fullPath, 'utf-8');
|
|
681
|
+
} catch {
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
if (!this._fileMentionsAsset(content, sourceRel, rel)) continue;
|
|
685
|
+
if (this._scopeSlugsMatchingText(String(sourceRel || '').toLowerCase(), allowedSlugs).length > 0) {
|
|
686
|
+
scopedSources.push(sourceRel);
|
|
687
|
+
} else {
|
|
688
|
+
outOfScopeMentions.push(sourceRel);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return scopedSources.length > 0 && outOfScopeMentions.length === 0;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
_scopeAllowsChangedPath(baseline, rel, allowedSlugs, pageOnly) {
|
|
696
|
+
const lower = String(rel || '').toLowerCase();
|
|
697
|
+
const slugAllowed = this._scopeSlugsMatchingText(lower, allowedSlugs).length > 0;
|
|
698
|
+
if (slugAllowed) return !pageOnly || this._isPageSourcePath(rel);
|
|
699
|
+
return this._isNewScopedPageOwnedAsset(baseline, rel, allowedSlugs, pageOnly);
|
|
700
|
+
}
|
|
701
|
+
|
|
629
702
|
_findScopeDriftRepoChanges(repoBaselines, userMessage) {
|
|
630
703
|
if (!Array.isArray(repoBaselines) || repoBaselines.length === 0) return [];
|
|
631
704
|
const { slugs: allowedSlugs, pageOnly } = this._extractExplicitScope(userMessage);
|
|
@@ -652,12 +725,7 @@ export class AgentForgeWorker extends EventEmitter {
|
|
|
652
725
|
const initialDirty = new Set(baseline.initialDirtyPaths || []);
|
|
653
726
|
const outOfScope = [...names]
|
|
654
727
|
.filter(rel => !initialDirty.has(rel))
|
|
655
|
-
.filter(rel =>
|
|
656
|
-
const lower = rel.toLowerCase();
|
|
657
|
-
const slugAllowed = allowedSlugs.some(slug => lower.includes(slug));
|
|
658
|
-
if (!slugAllowed) return true;
|
|
659
|
-
return pageOnly && !this._isPageSourcePath(rel);
|
|
660
|
-
})
|
|
728
|
+
.filter(rel => !this._scopeAllowsChangedPath(baseline, rel, allowedSlugs, pageOnly))
|
|
661
729
|
.sort();
|
|
662
730
|
if (outOfScope.length > 0) {
|
|
663
731
|
warnings.push({ repo: baseline.root, head: baseline.head, allowedSlugs, pageOnly, files: outOfScope });
|
|
@@ -723,11 +791,7 @@ export class AgentForgeWorker extends EventEmitter {
|
|
|
723
791
|
const initialDirty = new Set(baseline.initialDirtyPaths || []);
|
|
724
792
|
const files = [...names]
|
|
725
793
|
.filter(rel => resetPreexistingScopedTargets || !initialDirty.has(rel))
|
|
726
|
-
.filter(rel =>
|
|
727
|
-
const lower = rel.toLowerCase();
|
|
728
|
-
if (!allowedSlugs.some(slug => lower.includes(slug))) return false;
|
|
729
|
-
return !pageOnly || this._isPageSourcePath(rel);
|
|
730
|
-
})
|
|
794
|
+
.filter(rel => this._scopeAllowsChangedPath(baseline, rel, allowedSlugs, pageOnly))
|
|
731
795
|
.sort();
|
|
732
796
|
if (files.length === 0) continue;
|
|
733
797
|
|
|
@@ -799,7 +863,7 @@ export class AgentForgeWorker extends EventEmitter {
|
|
|
799
863
|
const pathName = rel.includes(' -> ') ? rel.split(' -> ').pop() : rel;
|
|
800
864
|
const lower = String(pathName || '').toLowerCase();
|
|
801
865
|
if (!this._isPageSourcePath(lower)) continue;
|
|
802
|
-
if (
|
|
866
|
+
if (this._scopeSlugsMatchingText(lower, allowedSlugs).length === 0) continue;
|
|
803
867
|
deleted.push(pathName);
|
|
804
868
|
}
|
|
805
869
|
if (deleted.length > 0) {
|
|
@@ -997,7 +1061,7 @@ export class AgentForgeWorker extends EventEmitter {
|
|
|
997
1061
|
}
|
|
998
1062
|
if (!currentFile || !uiFileRe.test(currentFile)) continue;
|
|
999
1063
|
const lowerPath = currentFile.toLowerCase();
|
|
1000
|
-
if (allowedSlugs.length > 0 &&
|
|
1064
|
+
if (allowedSlugs.length > 0 && this._scopeSlugsMatchingText(lowerPath, allowedSlugs).length === 0) continue;
|
|
1001
1065
|
if (!/^[+-][^+-]/.test(line)) continue;
|
|
1002
1066
|
const raw = line.slice(1).trim();
|
|
1003
1067
|
if (!raw || /^(?:\/\*|\*|\/\/|<!--|-->|})$/.test(raw)) continue;
|
|
@@ -1178,7 +1242,7 @@ export class AgentForgeWorker extends EventEmitter {
|
|
|
1178
1242
|
}
|
|
1179
1243
|
if (!currentFile || !uiFileRe.test(currentFile)) continue;
|
|
1180
1244
|
const lower = currentFile.toLowerCase();
|
|
1181
|
-
if (allowedSlugs.length > 0 &&
|
|
1245
|
+
if (allowedSlugs.length > 0 && this._scopeSlugsMatchingText(lower, allowedSlugs).length === 0) continue;
|
|
1182
1246
|
const addedLine = line.slice(1);
|
|
1183
1247
|
const newSourceComment = (() => {
|
|
1184
1248
|
commentTextRe.lastIndex = 0;
|
|
@@ -1289,7 +1353,7 @@ export class AgentForgeWorker extends EventEmitter {
|
|
|
1289
1353
|
for (const filePath of changedFiles) {
|
|
1290
1354
|
const lower = String(filePath || '').toLowerCase();
|
|
1291
1355
|
for (const slug of allowedSlugs) {
|
|
1292
|
-
if (
|
|
1356
|
+
if (this._scopeSlugsMatchingText(lower, [slug]).length > 0) touchedSlugs.add(slug);
|
|
1293
1357
|
}
|
|
1294
1358
|
}
|
|
1295
1359
|
|