@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hamp10/agentforge",
3
- "version": "0.2.33",
3
+ "version": "0.2.35",
4
4
  "description": "AgentForge worker — connect your machine to agentforge.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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,
@@ -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
- if (this._gitTracksPathOrPrefix(workDir, relative, firstPart)) return false;
849
- return true;
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 || !existsSync(canonicalPath)) return null;
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 (!this._gitTracksPathOrPrefix(workDir, canonicalRel, canonicalRel.split(path.sep)[0])) return null;
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 when the canonical target file also exists.',
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}`,
@@ -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
- return /^\s+(?:listing\s+)?(?:pages?|screens?|routes?|views?|listings?)\s+(?:for|of|called|named)\b/i.test(after);
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}?)(?=$|[?!;]|\.(?:\s|$)|\s+(?:on|at|in|from|with|without|while|using|preserv(?:e|ing)|that|which|where|when|because|so|but)\b)/gi;
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}?)(?=$|[?!;]|\.(?:\s|$)|\s+(?:on|at|in|from|with|without|while|using|preserv(?:e|ing)|that|which|where|when|because|so|but)\b)/i);
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 {