@hamp10/agentforge 0.2.30 → 0.2.32

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.30",
3
+ "version": "0.2.32",
4
4
  "description": "AgentForge worker — connect your machine to agentforge.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -437,6 +437,15 @@ try {
437
437
  );
438
438
  rmSync(sharedCssFile, { force: true });
439
439
  writeFileSync(alphaFixturePath, alphaFixtureCurrent);
440
+ const wrongRouteDir = path.join(fixture.repo, 'public_html', 'alpha');
441
+ mkdirSync(wrongRouteDir, { recursive: true });
442
+ writeFileSync(path.join(wrongRouteDir, 'index.html'), '<!doctype html><html><body><h1>Alpha</h1></body></html>');
443
+ assert.match(
444
+ worker._formatScopeDriftNudge(worker._findScopeDriftRepoChanges(baseline, message)),
445
+ /public_html\/alpha\/index\.html/i,
446
+ 'new same-scope route folders should be reported as out-of-scope when the project already has a page collection'
447
+ );
448
+ rmSync(wrongRouteDir, { recursive: true, force: true });
440
449
  const directOldHtml = '<!doctype html><html><body><main><section class="hero"><h1>Alpha</h1><p>Existing body copy.</p></section></main></body></html>';
441
450
  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>';
442
451
  assert.throws(
@@ -552,9 +561,34 @@ try {
552
561
  fixture.repo,
553
562
  { task: 'Work on the listing page for Alpha. Only change that listing page.' }
554
563
  ),
555
- /existing scoped page target/i,
564
+ /existing (?:scoped page target|page collection)/i,
556
565
  'scoped page work should not create a new same-slug page when an existing scoped target page is present'
557
566
  );
567
+ assert.throws(
568
+ () => cli._guardDirectFileWritePath(
569
+ path.join(fixture.repo, 'public_html', 'alpha', 'index.html'),
570
+ fixture.repo,
571
+ { task: message }
572
+ ),
573
+ /existing page collection/i,
574
+ 'scoped page work should not create a route folder when the project has an existing page collection pattern'
575
+ );
576
+ const nestedDuplicateGamma = path.join(fixture.repo, path.basename(fixture.repo), 'public_html', 'domains', 'gamma.html');
577
+ mkdirSync(path.dirname(nestedDuplicateGamma), { recursive: true });
578
+ writeFileSync(nestedDuplicateGamma, '<!doctype html><html><body><h1>Gamma</h1></body></html>');
579
+ assert.doesNotThrow(
580
+ () => cli._guardDirectFileWritePath(
581
+ path.join(fixture.repo, 'public_html', 'domains', 'gamma.html'),
582
+ fixture.repo,
583
+ { task: 'Can you make listing pages for Gamma?' }
584
+ ),
585
+ 'nested duplicate project copies should not block recreating the canonical page path'
586
+ );
587
+ assert.equal(
588
+ cli._directScopeFileCandidates(fixture.repo, ['gamma']).some(candidate => candidate.startsWith(`${path.basename(fixture.repo)}/`)),
589
+ false,
590
+ 'scope candidates should not steer agents toward nested project-copy paths when the canonical parent exists'
591
+ );
558
592
  const extractedOldCss = Array.from({ length: 28 }, (_, i) =>
559
593
  `.hero-polish-${i} { color: #${String(100000 + i).slice(0, 6)}; padding: ${i + 1}px; margin: ${i}px; box-shadow: 0 0 ${i + 2}px rgba(0,0,0,.1); }`
560
594
  ).join('\n');
@@ -647,14 +681,28 @@ try {
647
681
  ),
648
682
  'scoped shell sed should ignore the substitution script and allow canonical target page edits'
649
683
  );
684
+ assert.throws(
685
+ () => cli._guardDirectBashCommand(
686
+ 'touch public_html/css/domain-detail.css',
687
+ fixture.repo,
688
+ { task: message }
689
+ ),
690
+ /outside the requested task scope/i,
691
+ 'direct scoped work should reject new generic CSS assets before the target page owns or links them'
692
+ );
693
+ writeFileSync(
694
+ alphaFixturePath,
695
+ alphaFixtureCurrent.replace('</head>', '<link rel="stylesheet" href="../css/domain-detail.css">\n</head>')
696
+ );
650
697
  assert.doesNotThrow(
651
698
  () => cli._guardDirectBashCommand(
652
699
  'touch public_html/css/domain-detail.css',
653
700
  fixture.repo,
654
701
  { task: message }
655
702
  ),
656
- 'direct scoped work should allow creating a new page-owned CSS asset before the target page links it'
703
+ 'direct scoped work should allow generic CSS assets after they are linked only by scoped pages'
657
704
  );
705
+ writeFileSync(alphaFixturePath, alphaFixtureCurrent);
658
706
  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>';
659
707
  assert.throws(
660
708
  () => cli._validateDirectUiFileContent(
@@ -617,12 +617,65 @@ export class OpenClawCLI extends EventEmitter {
617
617
  return /\.(?:html?|xhtml|astro|mdx?)$/i.test(String(relativePath || ''));
618
618
  }
619
619
 
620
- _isDirectNewPageOwnedAssetPath(filePath, workDir, relativePath, pageOnly) {
620
+ _directFileMentionsAsset(content, sourceRel, assetRel) {
621
+ const source = String(content || '');
622
+ const assetUnix = String(assetRel || '').replace(/\\/g, '/');
623
+ const assetBase = path.basename(assetUnix);
624
+ const relativeFromSource = path.relative(path.dirname(sourceRel || ''), assetRel || '').replace(/\\/g, '/');
625
+ const candidates = [
626
+ assetBase,
627
+ assetUnix,
628
+ `/${assetUnix}`,
629
+ relativeFromSource,
630
+ relativeFromSource && !relativeFromSource.startsWith('.') ? `./${relativeFromSource}` : '',
631
+ ].filter(Boolean);
632
+ return candidates.some(candidate => source.includes(candidate));
633
+ }
634
+
635
+ _directCurrentPageSourceFiles(workDir) {
636
+ const names = new Set();
637
+ const addLines = (output) => {
638
+ for (const rel of String(output || '').split('\n').map(line => line.trim()).filter(Boolean)) {
639
+ if (this._isDirectPageSourcePath(rel)) names.add(rel);
640
+ }
641
+ };
642
+ try {
643
+ addLines(execFileSync('git', ['-C', workDir, 'ls-files'], {
644
+ encoding: 'utf-8',
645
+ stdio: ['ignore', 'pipe', 'ignore'],
646
+ }));
647
+ addLines(execFileSync('git', ['-C', workDir, 'ls-files', '--others', '--exclude-standard'], {
648
+ encoding: 'utf-8',
649
+ stdio: ['ignore', 'pipe', 'ignore'],
650
+ }));
651
+ } catch {}
652
+ return [...names].sort();
653
+ }
654
+
655
+ _isDirectNewPageOwnedAssetPath(filePath, workDir, relativePath, pageOnly, slugs = []) {
621
656
  if (!pageOnly || !this._isDirectPageAssetPath(relativePath)) return false;
622
- if (existsSync(filePath)) return false;
623
657
  const relative = path.relative(workDir, filePath);
624
658
  if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return false;
625
- return true;
659
+ if (this._directScopeSlugsMatchingText(String(relativePath || '').toLowerCase(), slugs).length > 0) return true;
660
+
661
+ const sourceFiles = this._directCurrentPageSourceFiles(workDir).filter(file => file !== relativePath);
662
+ const scopedSources = [];
663
+ const outOfScopeMentions = [];
664
+ for (const sourceRel of sourceFiles) {
665
+ let content = '';
666
+ try {
667
+ content = readFileSync(path.join(workDir, sourceRel), 'utf-8');
668
+ } catch {
669
+ continue;
670
+ }
671
+ if (!this._directFileMentionsAsset(content, sourceRel, relativePath)) continue;
672
+ if (this._directScopeSlugsMatchingText(String(sourceRel || '').toLowerCase(), slugs).length > 0) {
673
+ scopedSources.push(sourceRel);
674
+ } else {
675
+ outOfScopeMentions.push(sourceRel);
676
+ }
677
+ }
678
+ return scopedSources.length > 0 && outOfScopeMentions.length === 0;
626
679
  }
627
680
 
628
681
  _isDirectScopedReferenceSourcePath(filePath, workDir, relativePath, slugs, pageOnly, task) {
@@ -659,11 +712,71 @@ export class OpenClawCLI extends EventEmitter {
659
712
  const slugAllowed = this._directScopeSlugsMatchingText(lower, slugs).length > 0;
660
713
  if (!slugAllowed) {
661
714
  if (this._isDirectScopedReferenceSourcePath(filePath, workDir, scopePath, slugs, pageOnly, task)) return true;
662
- return this._isDirectNewPageOwnedAssetPath(filePath, workDir, scopePath, pageOnly);
715
+ return this._isDirectNewPageOwnedAssetPath(filePath, workDir, scopePath, pageOnly, slugs);
663
716
  }
664
717
  return !pageOnly || this._isDirectPageSourcePath(scopePath);
665
718
  }
666
719
 
720
+ _directHtmlPageCollectionDirs(workDir) {
721
+ if (!workDir) return [];
722
+ const counts = new Map();
723
+ try {
724
+ const output = execFileSync('git', ['-C', workDir, 'ls-files'], {
725
+ encoding: 'utf-8',
726
+ stdio: ['ignore', 'pipe', 'ignore'],
727
+ });
728
+ for (const rel of String(output || '').split('\n').map(line => line.trim()).filter(Boolean)) {
729
+ if (!this._isDirectHtmlPagePath(rel)) continue;
730
+ const absolute = path.resolve(workDir, rel);
731
+ if (this._nestedProjectCopyCanonicalPath(absolute, workDir)) continue;
732
+ const dir = path.dirname(rel);
733
+ if (!dir || dir === '.') continue;
734
+ counts.set(dir, (counts.get(dir) || 0) + 1);
735
+ }
736
+ } catch {
737
+ return [];
738
+ }
739
+ const dirs = [...counts.entries()]
740
+ .filter(([, count]) => count >= 2)
741
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
742
+ .map(([dir]) => dir);
743
+ return dirs.filter(dir => !dirs.some(other => other !== dir && this._directPathInsideRelDir(other, dir)));
744
+ }
745
+
746
+ _directPathInsideRelDir(relativePath, dir) {
747
+ const rel = path.relative(path.normalize(dir), path.normalize(relativePath));
748
+ return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
749
+ }
750
+
751
+ _directNewHtmlPageCollectionViolation(filePath, workDir, slugs, pageOnly, relativePath) {
752
+ if (!pageOnly || !Array.isArray(slugs) || slugs.length === 0) return null;
753
+ if (!this._isDirectHtmlPagePath(relativePath)) return null;
754
+ const lowerRelativePath = String(relativePath || '').toLowerCase();
755
+ const matchedSlugs = this._directScopeSlugsMatchingText(lowerRelativePath, slugs);
756
+ if (matchedSlugs.length === 0) return null;
757
+ if (this._gitTracksExactPath(workDir, relativePath)) return null;
758
+
759
+ const collectionDirs = this._directHtmlPageCollectionDirs(workDir);
760
+ if (collectionDirs.length === 0) return null;
761
+ if (collectionDirs.some(dir => this._directPathInsideRelDir(relativePath, dir))) return null;
762
+
763
+ const candidatePaths = [];
764
+ for (const dir of collectionDirs.slice(0, 4)) {
765
+ for (const slug of matchedSlugs) {
766
+ const aliases = new Set([slug]);
767
+ const withoutSuffix = slug.replace(/-(?:ai|app|co|com|dev|gg|io|net|org|so|xyz)$/i, '');
768
+ if (withoutSuffix && withoutSuffix !== slug && withoutSuffix.length >= 3) aliases.add(withoutSuffix);
769
+ for (const alias of aliases) candidatePaths.push(path.join(dir, `${alias}.html`));
770
+ }
771
+ }
772
+
773
+ return {
774
+ requested: relativePath,
775
+ collectionDirs,
776
+ candidatePaths: [...new Set(candidatePaths)].slice(0, 8),
777
+ };
778
+ }
779
+
667
780
  _directScopeFileCandidates(workDir, slugs, maxResults = 8) {
668
781
  if (!workDir || !Array.isArray(slugs) || slugs.length === 0) return [];
669
782
  const results = [];
@@ -694,7 +807,10 @@ export class OpenClawCLI extends EventEmitter {
694
807
  walk(workDir, 0);
695
808
  const resultSet = new Set(results);
696
809
  return results.filter(rel => {
697
- const canonical = this._nestedProjectDuplicateCanonicalPath(path.resolve(workDir, rel), workDir);
810
+ const absolute = path.resolve(workDir, rel);
811
+ const nestedCanonical = this._nestedProjectCopyCanonicalPath(absolute, workDir);
812
+ if (nestedCanonical && existsSync(path.dirname(nestedCanonical))) return false;
813
+ const canonical = this._nestedProjectDuplicateCanonicalPath(absolute, workDir);
698
814
  if (!canonical) return true;
699
815
  const canonicalRel = path.relative(workDir, canonical) || canonical;
700
816
  return !resultSet.has(canonicalRel);
@@ -849,6 +965,24 @@ export class OpenClawCLI extends EventEmitter {
849
965
  if (!this._directTaskScopeAllowsPath(filePath, workDir, options.task)) {
850
966
  throw this._directScopeViolationError(this._formatDirectScopeError(filePath, workDir, options.task));
851
967
  }
968
+ const collectionViolation = this._directNewHtmlPageCollectionViolation(
969
+ filePath,
970
+ workDir,
971
+ slugs,
972
+ pageOnly,
973
+ relativePath
974
+ );
975
+ if (collectionViolation) {
976
+ throw this._directScopeViolationError([
977
+ 'Refusing to create a new route/page path outside the project\'s existing page collection for this scoped page task.',
978
+ `Requested new page: ${collectionViolation.requested}`,
979
+ `Existing page collection director${collectionViolation.collectionDirs.length === 1 ? 'y' : 'ies'}: ${collectionViolation.collectionDirs.slice(0, 6).join(', ')}`,
980
+ collectionViolation.candidatePaths.length > 0
981
+ ? `Likely target path(s): ${collectionViolation.candidatePaths.join(', ')}`
982
+ : '',
983
+ 'Use the project\'s existing page collection pattern instead of inventing a sibling URL or route folder.',
984
+ ].filter(Boolean).join(' '));
985
+ }
852
986
  if (pageOnly && slugs.length > 0 && this._isDirectHtmlPagePath(relativePath)) {
853
987
  const matchedSlugs = this._directScopeSlugsMatchingText(lowerRelativePath, slugs);
854
988
  const isTrackedTarget = this._gitTracksExactPath(workDir, relativePath);
package/src/worker.js CHANGED
@@ -646,6 +646,47 @@ export class AgentForgeWorker extends EventEmitter {
646
646
  return /\.(css|scss|sass|less|jsx?|tsx?)$/i.test(String(relativePath || ''));
647
647
  }
648
648
 
649
+ _isHtmlPagePath(relativePath) {
650
+ return /\.(?:html?|xhtml|astro|mdx?)$/i.test(String(relativePath || ''));
651
+ }
652
+
653
+ _htmlPageCollectionDirs(repo, ref = 'HEAD') {
654
+ if (!repo) return [];
655
+ const counts = new Map();
656
+ const output = this._gitOutput(repo, ['ls-tree', '-r', '--name-only', ref || 'HEAD'], 50000)
657
+ || this._gitOutput(repo, ['ls-files'], 50000);
658
+ for (const rel of String(output || '').split('\n').map(line => line.trim()).filter(Boolean)) {
659
+ if (!this._isHtmlPagePath(rel)) continue;
660
+ const parts = rel.split(/[\\/]+/).filter(Boolean);
661
+ if (parts.length >= 2 && parts[0].toLowerCase() === path.basename(repo).toLowerCase()) continue;
662
+ const dir = path.dirname(rel);
663
+ if (!dir || dir === '.') continue;
664
+ counts.set(dir, (counts.get(dir) || 0) + 1);
665
+ }
666
+ const dirs = [...counts.entries()]
667
+ .filter(([, count]) => count >= 2)
668
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
669
+ .map(([dir]) => dir);
670
+ return dirs.filter(dir => !dirs.some(other => other !== dir && this._pathInsideRelDir(other, dir)));
671
+ }
672
+
673
+ _pathInsideRelDir(relativePath, dir) {
674
+ const rel = path.relative(path.normalize(dir), path.normalize(relativePath));
675
+ return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
676
+ }
677
+
678
+ _isNewHtmlPageOutsideExistingCollection(baseline, rel, allowedSlugs, pageOnly) {
679
+ if (!baseline?.root || !pageOnly || !this._isHtmlPagePath(rel)) return false;
680
+ if (!Array.isArray(allowedSlugs) || allowedSlugs.length === 0) return false;
681
+ const lower = String(rel || '').toLowerCase();
682
+ if (this._scopeSlugsMatchingText(lower, allowedSlugs).length === 0) return false;
683
+ if (this._gitPathExistsAtRef(baseline.root, baseline.head || 'HEAD', rel)) return false;
684
+
685
+ const collectionDirs = this._htmlPageCollectionDirs(baseline.root, baseline.head || 'HEAD');
686
+ if (collectionDirs.length === 0) return false;
687
+ return !collectionDirs.some(dir => this._pathInsideRelDir(rel, dir));
688
+ }
689
+
649
690
  _fileMentionsAsset(content, sourceRel, assetRel) {
650
691
  const source = String(content || '');
651
692
  const assetUnix = String(assetRel || '').replace(/\\/g, '/');
@@ -723,7 +764,10 @@ export class AgentForgeWorker extends EventEmitter {
723
764
  _scopeAllowsChangedPath(baseline, rel, allowedSlugs, pageOnly, userMessage = '') {
724
765
  const lower = String(rel || '').toLowerCase();
725
766
  const slugAllowed = this._scopeSlugsMatchingText(lower, allowedSlugs).length > 0;
726
- if (slugAllowed) return !pageOnly || this._isPageSourcePath(rel);
767
+ if (slugAllowed) {
768
+ if (this._isNewHtmlPageOutsideExistingCollection(baseline, rel, allowedSlugs, pageOnly)) return false;
769
+ return !pageOnly || this._isPageSourcePath(rel);
770
+ }
727
771
  if (this._isScopedReferenceSourcePath(baseline, rel, allowedSlugs, pageOnly, userMessage)) return true;
728
772
  return this._isNewScopedPageOwnedAsset(baseline, rel, allowedSlugs, pageOnly);
729
773
  }