@hamp10/agentforge 0.2.33 → 0.2.35
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 +52 -0
- package/src/OpenClawCLI.js +61 -5
- package/src/taskSemantics.js +20 -3
- package/src/worker.js +22 -0
package/package.json
CHANGED
|
@@ -29,6 +29,12 @@ const cases = [
|
|
|
29
29
|
absent: ['example-com', 'visual', 'excellent'],
|
|
30
30
|
pageOnly: true,
|
|
31
31
|
},
|
|
32
|
+
{
|
|
33
|
+
text: 'Can you make Example.com listing pages for AlphaBoard.ai and BetaMatch.ai, and fix the GammaForge.ai listing page?',
|
|
34
|
+
slugs: ['alphaboard-ai', 'betamatch-ai', 'gammaforge-ai'],
|
|
35
|
+
absent: ['example-com', 'make', 'fix'],
|
|
36
|
+
pageOnly: true,
|
|
37
|
+
},
|
|
32
38
|
{
|
|
33
39
|
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.',
|
|
34
40
|
slugs: ['alphaboard', 'betamatch'],
|
|
@@ -179,6 +185,10 @@ const makeShallowUiFixture = () => {
|
|
|
179
185
|
writeFileSync(path.join(domainsDir, `${name}.html`), baselineHtml);
|
|
180
186
|
writeFileSync(path.join(nestedDomainsDir, `${name}.html`), baselineHtml);
|
|
181
187
|
}
|
|
188
|
+
writeFileSync(
|
|
189
|
+
path.join(repo, 'public_html', 'domains.html'),
|
|
190
|
+
'<!doctype html><html><body><main><a href="/domains/alpha.html">Alpha</a><a href="/domains/beta.html">Beta</a></main></body></html>'
|
|
191
|
+
);
|
|
182
192
|
git(repo, ['init']);
|
|
183
193
|
git(repo, ['config', 'user.email', 'test@example.com']);
|
|
184
194
|
git(repo, ['config', 'user.name', 'AgentForge Test']);
|
|
@@ -337,6 +347,24 @@ try {
|
|
|
337
347
|
);
|
|
338
348
|
const message = 'Improve the listing pages for Alpha and Beta so they feel polished.';
|
|
339
349
|
const baseline = [{ root: fixture.repo, head: fixture.head }];
|
|
350
|
+
const domainsIndexPath = path.join(fixture.repo, 'public_html', 'domains.html');
|
|
351
|
+
const domainsIndexCurrent = readFileSync(domainsIndexPath, 'utf-8');
|
|
352
|
+
const deleteOnlyScopedPagesMessage = 'Delete the Example.com listing pages for Alpha and Beta. Only change those listing pages.';
|
|
353
|
+
writeFileSync(
|
|
354
|
+
domainsIndexPath,
|
|
355
|
+
domainsIndexCurrent.replace('</main>', '<p>Alpha and Beta are featured listing pages.</p></main>')
|
|
356
|
+
);
|
|
357
|
+
assert.match(
|
|
358
|
+
worker._formatScopeDriftNudge(worker._findScopeDriftRepoChanges(baseline, deleteOnlyScopedPagesMessage)),
|
|
359
|
+
/public_html\/domains\.html/i,
|
|
360
|
+
'broad listing indexes should not become editable targets just because they mention scoped page names'
|
|
361
|
+
);
|
|
362
|
+
assert.throws(
|
|
363
|
+
() => cli._guardDirectFileWritePath(domainsIndexPath, fixture.repo, { task: deleteOnlyScopedPagesMessage }),
|
|
364
|
+
/outside the requested task scope/i,
|
|
365
|
+
'direct scoped deletion work should reject broad listing indexes even when their content mentions scoped pages'
|
|
366
|
+
);
|
|
367
|
+
writeFileSync(domainsIndexPath, domainsIndexCurrent);
|
|
340
368
|
assert.match(
|
|
341
369
|
worker._formatScopedUiTargetSetReminder(message),
|
|
342
370
|
/Requested scoped UI targets: alpha, beta/i,
|
|
@@ -576,6 +604,15 @@ try {
|
|
|
576
604
|
const nestedDuplicateGamma = path.join(fixture.repo, path.basename(fixture.repo), 'public_html', 'domains', 'gamma.html');
|
|
577
605
|
mkdirSync(path.dirname(nestedDuplicateGamma), { recursive: true });
|
|
578
606
|
writeFileSync(nestedDuplicateGamma, '<!doctype html><html><body><h1>Gamma</h1></body></html>');
|
|
607
|
+
assert.throws(
|
|
608
|
+
() => cli._guardDirectFileWritePath(
|
|
609
|
+
nestedDuplicateGamma,
|
|
610
|
+
fixture.repo,
|
|
611
|
+
{ task: 'Can you make listing pages for Gamma?' }
|
|
612
|
+
),
|
|
613
|
+
/nested duplicate of the current project/i,
|
|
614
|
+
'same-name nested project-copy paths should be blocked even when the nested copy is tracked or preexisting'
|
|
615
|
+
);
|
|
579
616
|
assert.doesNotThrow(
|
|
580
617
|
() => cli._guardDirectFileWritePath(
|
|
581
618
|
path.join(fixture.repo, 'public_html', 'domains', 'gamma.html'),
|
|
@@ -584,6 +621,21 @@ try {
|
|
|
584
621
|
),
|
|
585
622
|
'nested duplicate project copies should not block recreating the canonical page path'
|
|
586
623
|
);
|
|
624
|
+
const linkedWorkspace = mkdtempSync(path.join(tmpdir(), 'agentforge-linked-project-'));
|
|
625
|
+
try {
|
|
626
|
+
const linkedProjectPath = path.join(linkedWorkspace, path.basename(fixture.repo));
|
|
627
|
+
symlinkSync(fixture.repo, linkedProjectPath, 'dir');
|
|
628
|
+
assert.doesNotThrow(
|
|
629
|
+
() => cli._guardDirectFileWritePath(
|
|
630
|
+
path.join(linkedProjectPath, 'public_html', 'domains', 'gamma.html'),
|
|
631
|
+
fixture.repo,
|
|
632
|
+
{ task: 'Can you make listing pages for Gamma?' }
|
|
633
|
+
),
|
|
634
|
+
'agent-workspace symlink paths should canonicalize to the real project before scoped page guard checks'
|
|
635
|
+
);
|
|
636
|
+
} finally {
|
|
637
|
+
rmSync(linkedWorkspace, { recursive: true, force: true });
|
|
638
|
+
}
|
|
587
639
|
assert.equal(
|
|
588
640
|
cli._directScopeFileCandidates(fixture.repo, ['gamma']).some(candidate => candidate.startsWith(`${path.basename(fixture.repo)}/`)),
|
|
589
641
|
false,
|
package/src/OpenClawCLI.js
CHANGED
|
@@ -381,6 +381,38 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
381
381
|
return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
_realPathForMaybeMissingPath(targetPath) {
|
|
385
|
+
const resolved = path.resolve(String(targetPath || ''));
|
|
386
|
+
if (!resolved) return resolved;
|
|
387
|
+
if (existsSync(resolved)) {
|
|
388
|
+
try {
|
|
389
|
+
return realpathSync(resolved);
|
|
390
|
+
} catch {
|
|
391
|
+
return resolved;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const missingParts = [];
|
|
395
|
+
let cursor = resolved;
|
|
396
|
+
while (cursor && !existsSync(cursor)) {
|
|
397
|
+
const parent = path.dirname(cursor);
|
|
398
|
+
if (!parent || parent === cursor) break;
|
|
399
|
+
missingParts.unshift(path.basename(cursor));
|
|
400
|
+
cursor = parent;
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
return path.join(realpathSync(cursor), ...missingParts);
|
|
404
|
+
} catch {
|
|
405
|
+
return resolved;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
_canonicalizeLinkedProjectPath(filePath, workDir) {
|
|
410
|
+
if (!filePath || !workDir) return filePath;
|
|
411
|
+
const realFilePath = this._realPathForMaybeMissingPath(filePath);
|
|
412
|
+
const realWorkDir = this._realPathForMaybeMissingPath(workDir);
|
|
413
|
+
return this._isPathInside(realFilePath, realWorkDir) ? realFilePath : filePath;
|
|
414
|
+
}
|
|
415
|
+
|
|
384
416
|
_projectsRootForPath(candidate) {
|
|
385
417
|
if (!candidate) return null;
|
|
386
418
|
return this._knownProjectsRoots().find(root => existsSync(root) && this._isPathInside(candidate, root)) || null;
|
|
@@ -617,6 +649,27 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
617
649
|
return /\.(?:html?|xhtml|astro|mdx?)$/i.test(String(relativePath || ''));
|
|
618
650
|
}
|
|
619
651
|
|
|
652
|
+
_isDirectBroadReferenceSourcePath(relativePath) {
|
|
653
|
+
const normalized = String(relativePath || '').replace(/\\/g, '/').toLowerCase();
|
|
654
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
655
|
+
const base = path.basename(normalized).replace(/\.[^.]+$/, '');
|
|
656
|
+
const broadBase = new Set([
|
|
657
|
+
'index', 'home', 'main', 'app', 'root',
|
|
658
|
+
'domain', 'domains', 'listing', 'listings', 'catalog', 'directory', 'archive',
|
|
659
|
+
'portfolio', 'about', 'contact', 'support',
|
|
660
|
+
'header', 'footer', 'nav', 'navbar', 'navigation',
|
|
661
|
+
'layout', 'layouts', 'template', 'templates',
|
|
662
|
+
'base', 'shared', 'global', 'common', 'universal',
|
|
663
|
+
'theme', 'themes', 'tokens', 'components', 'component',
|
|
664
|
+
'reset', 'site', 'website',
|
|
665
|
+
]);
|
|
666
|
+
if (broadBase.has(base)) return true;
|
|
667
|
+
if (/(^|[-_.])(domain|domains|listing|listings|catalog|directory|portfolio|layout|template|base|shared|global|common|universal|theme|tokens?|components?|reset|site|website)([-_.]|$)/i.test(base)) {
|
|
668
|
+
return true;
|
|
669
|
+
}
|
|
670
|
+
return parts.slice(0, -1).some(part => /^(components?|layouts?|partials?|includes?|shared|common|global|styles?|theme|tokens?|templates?)$/i.test(part));
|
|
671
|
+
}
|
|
672
|
+
|
|
620
673
|
_directFileMentionsAsset(content, sourceRel, assetRel) {
|
|
621
674
|
const source = String(content || '');
|
|
622
675
|
const assetUnix = String(assetRel || '').replace(/\\/g, '/');
|
|
@@ -685,6 +738,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
685
738
|
|
|
686
739
|
const relative = path.relative(workDir, filePath);
|
|
687
740
|
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return false;
|
|
741
|
+
if (this._isDirectBroadReferenceSourcePath(relativePath)) return false;
|
|
688
742
|
|
|
689
743
|
const contents = [];
|
|
690
744
|
try {
|
|
@@ -845,8 +899,8 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
845
899
|
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return false;
|
|
846
900
|
const [firstPart] = relative.split(path.sep).filter(Boolean);
|
|
847
901
|
if (!firstPart || firstPart.toLowerCase() !== path.basename(workDir).toLowerCase()) return false;
|
|
848
|
-
|
|
849
|
-
return
|
|
902
|
+
const canonicalPath = this._nestedProjectCopyCanonicalPath(filePath, workDir);
|
|
903
|
+
return !!canonicalPath && (existsSync(canonicalPath) || existsSync(path.dirname(canonicalPath)));
|
|
850
904
|
}
|
|
851
905
|
|
|
852
906
|
_gitTracksPathOrPrefix(workDir, relativePath, prefix = '') {
|
|
@@ -890,11 +944,11 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
890
944
|
|
|
891
945
|
_nestedProjectDuplicateCanonicalPath(filePath, workDir) {
|
|
892
946
|
const canonicalPath = this._nestedProjectCopyCanonicalPath(filePath, workDir);
|
|
893
|
-
if (!canonicalPath || canonicalPath === filePath
|
|
947
|
+
if (!canonicalPath || canonicalPath === filePath) return null;
|
|
894
948
|
if (!this._isDirectPageSourcePath(filePath) || !this._isDirectPageSourcePath(canonicalPath)) return null;
|
|
895
949
|
const canonicalRel = path.relative(workDir, canonicalPath);
|
|
896
950
|
if (!canonicalRel || canonicalRel.startsWith('..') || path.isAbsolute(canonicalRel)) return null;
|
|
897
|
-
if (!
|
|
951
|
+
if (!existsSync(canonicalPath) && !existsSync(path.dirname(canonicalPath))) return null;
|
|
898
952
|
return canonicalPath;
|
|
899
953
|
}
|
|
900
954
|
|
|
@@ -934,6 +988,8 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
934
988
|
}
|
|
935
989
|
|
|
936
990
|
_guardDirectFileWritePath(filePath, workDir, options = {}) {
|
|
991
|
+
filePath = this._canonicalizeLinkedProjectPath(filePath, workDir);
|
|
992
|
+
workDir = this._realPathForMaybeMissingPath(workDir);
|
|
937
993
|
const { slugs, pageOnly } = this._extractDirectExplicitScope(options.task);
|
|
938
994
|
const relative = path.relative(workDir, filePath);
|
|
939
995
|
const relativePath = (!relative || relative.startsWith('..') || path.isAbsolute(relative))
|
|
@@ -945,7 +1001,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
945
1001
|
: null;
|
|
946
1002
|
if (duplicateCanonicalPath) {
|
|
947
1003
|
throw this._directScopeViolationError([
|
|
948
|
-
'Refusing to write into a nested duplicate of the current project
|
|
1004
|
+
'Refusing to write into a nested duplicate of the current project.',
|
|
949
1005
|
`Workspace: ${workDir}`,
|
|
950
1006
|
`Target: ${filePath}`,
|
|
951
1007
|
`Use the canonical project path instead: ${duplicateCanonicalPath}`,
|
package/src/taskSemantics.js
CHANGED
|
@@ -20,7 +20,19 @@ const GENERIC_SCOPE_WORDS = new Set([
|
|
|
20
20
|
|
|
21
21
|
const followedByNamedPageTargets = (text, match) => {
|
|
22
22
|
const after = text.slice(match.index + match[0].length);
|
|
23
|
-
|
|
23
|
+
const target = after.match(/^\s+(?:listing\s+)?(?:pages?|screens?|routes?|views?|listings?)\s+(?:for|of|called|named)\s+(?:the\s+)?([a-z0-9][a-z0-9 ._/-]{0,160}?)(?=$|[?!;,]|\.(?:\s|$)|\s+(?:on|at|in|from|with|without|while|using|preserv(?:e|ing)|that|which|where|when|because|so|but)\b)/i);
|
|
24
|
+
if (!target) return false;
|
|
25
|
+
const targetCandidates = new Set();
|
|
26
|
+
addScopeCandidate(targetCandidates, target[1]);
|
|
27
|
+
targetCandidates.delete(scopeSlug(match[0]));
|
|
28
|
+
return targetCandidates.size > 0;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const matchFromCapture = (match, captureIndex = 1) => {
|
|
32
|
+
const value = match?.[captureIndex] || '';
|
|
33
|
+
const whole = match?.[0] || '';
|
|
34
|
+
const offset = whole.indexOf(value);
|
|
35
|
+
return { 0: value, index: (match?.index || 0) + (offset >= 0 ? offset : 0) };
|
|
24
36
|
};
|
|
25
37
|
|
|
26
38
|
export function scopeSlug(value) {
|
|
@@ -109,10 +121,11 @@ export function extractNamedPageScopeSlugs(message) {
|
|
|
109
121
|
|
|
110
122
|
const directBeforePage = /\b([a-z0-9][a-z0-9._/-]{2,80})\s+(?:pages?|screens?|routes?|views?|listings?)\b/gi;
|
|
111
123
|
for (const match of text.matchAll(directBeforePage)) {
|
|
124
|
+
if (followedByNamedPageTargets(text, matchFromCapture(match))) continue;
|
|
112
125
|
addScopeCandidate(candidates, match[1]);
|
|
113
126
|
}
|
|
114
127
|
|
|
115
|
-
const afterPage = /\b(?:pages?|screens?|routes?|views?|listings?)\s+(?:for|of|called|named)\s+(?:the\s+)?([a-z0-9][a-z0-9 ._/-]{0,160}?)(?=$|[
|
|
128
|
+
const afterPage = /\b(?:pages?|screens?|routes?|views?|listings?)\s+(?:for|of|called|named)\s+(?:the\s+)?([a-z0-9][a-z0-9 ._/-]{0,160}?)(?=$|[?!;,]|\.(?:\s|$)|\s+(?:on|at|in|from|with|without|while|using|preserv(?:e|ing)|that|which|where|when|because|so|but)\b)/gi;
|
|
116
129
|
for (const match of text.matchAll(afterPage)) {
|
|
117
130
|
const before = text.slice(Math.max(0, match.index - 120), match.index);
|
|
118
131
|
const precedingToken = before.match(/\b([a-z0-9][a-z0-9._/-]{2,})\s*$/i)?.[1] || '';
|
|
@@ -124,7 +137,7 @@ export function extractNamedPageScopeSlugs(message) {
|
|
|
124
137
|
|
|
125
138
|
for (const match of text.matchAll(/\b[a-z0-9]+(?:[.-][a-z0-9]+)+\b/gi)) {
|
|
126
139
|
const after = text.slice(match.index + match[0].length);
|
|
127
|
-
const pageTarget = after.match(/^\s+(?:listing\s+)?(?:pages?|screens?|routes?|views?|listings?)\s+(?:for|of|called|named)\s+(?:the\s+)?([a-z0-9][a-z0-9 ._/-]{0,160}?)(?=$|[
|
|
140
|
+
const pageTarget = after.match(/^\s+(?:listing\s+)?(?:pages?|screens?|routes?|views?|listings?)\s+(?:for|of|called|named)\s+(?:the\s+)?([a-z0-9][a-z0-9 ._/-]{0,160}?)(?=$|[?!;,]|\.(?:\s|$)|\s+(?:on|at|in|from|with|without|while|using|preserv(?:e|ing)|that|which|where|when|because|so|but)\b)/i);
|
|
128
141
|
if (pageTarget) {
|
|
129
142
|
const targetCandidates = new Set();
|
|
130
143
|
addScopeCandidate(targetCandidates, pageTarget[1]);
|
|
@@ -134,6 +147,10 @@ export function extractNamedPageScopeSlugs(message) {
|
|
|
134
147
|
}
|
|
135
148
|
}
|
|
136
149
|
|
|
150
|
+
for (const match of text.matchAll(/\b[a-z0-9]+(?:[.-][a-z0-9]+)+\b/gi)) {
|
|
151
|
+
if (followedByNamedPageTargets(text, match)) candidates.delete(scopeSlug(match[0]));
|
|
152
|
+
}
|
|
153
|
+
|
|
137
154
|
return [...candidates];
|
|
138
155
|
}
|
|
139
156
|
|
package/src/worker.js
CHANGED
|
@@ -650,6 +650,27 @@ export class AgentForgeWorker extends EventEmitter {
|
|
|
650
650
|
return /\.(?:html?|xhtml|astro|mdx?)$/i.test(String(relativePath || ''));
|
|
651
651
|
}
|
|
652
652
|
|
|
653
|
+
_isBroadReferenceSourcePath(relativePath) {
|
|
654
|
+
const normalized = String(relativePath || '').replace(/\\/g, '/').toLowerCase();
|
|
655
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
656
|
+
const base = path.basename(normalized).replace(/\.[^.]+$/, '');
|
|
657
|
+
const broadBase = new Set([
|
|
658
|
+
'index', 'home', 'main', 'app', 'root',
|
|
659
|
+
'domain', 'domains', 'listing', 'listings', 'catalog', 'directory', 'archive',
|
|
660
|
+
'portfolio', 'about', 'contact', 'support',
|
|
661
|
+
'header', 'footer', 'nav', 'navbar', 'navigation',
|
|
662
|
+
'layout', 'layouts', 'template', 'templates',
|
|
663
|
+
'base', 'shared', 'global', 'common', 'universal',
|
|
664
|
+
'theme', 'themes', 'tokens', 'components', 'component',
|
|
665
|
+
'reset', 'site', 'website',
|
|
666
|
+
]);
|
|
667
|
+
if (broadBase.has(base)) return true;
|
|
668
|
+
if (/(^|[-_.])(domain|domains|listing|listings|catalog|directory|portfolio|layout|template|base|shared|global|common|universal|theme|tokens?|components?|reset|site|website)([-_.]|$)/i.test(base)) {
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
return parts.slice(0, -1).some(part => /^(components?|layouts?|partials?|includes?|shared|common|global|styles?|theme|tokens?|templates?)$/i.test(part));
|
|
672
|
+
}
|
|
673
|
+
|
|
653
674
|
_htmlPageCollectionDirs(repo, ref = 'HEAD') {
|
|
654
675
|
if (!repo) return [];
|
|
655
676
|
const counts = new Map();
|
|
@@ -748,6 +769,7 @@ export class AgentForgeWorker extends EventEmitter {
|
|
|
748
769
|
if (!baseline?.root || !pageOnly || !this._allowsScopedPageSourcesToRemainDeleted(userMessage)) return false;
|
|
749
770
|
if (!Array.isArray(allowedSlugs) || allowedSlugs.length === 0) return false;
|
|
750
771
|
if (!this._isPageSourcePath(rel)) return false;
|
|
772
|
+
if (this._isBroadReferenceSourcePath(rel)) return false;
|
|
751
773
|
|
|
752
774
|
let content = '';
|
|
753
775
|
try {
|