@hamp10/agentforge 0.2.31 → 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.31",
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,18 @@ 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
+ );
558
576
  const nestedDuplicateGamma = path.join(fixture.repo, path.basename(fixture.repo), 'public_html', 'domains', 'gamma.html');
559
577
  mkdirSync(path.dirname(nestedDuplicateGamma), { recursive: true });
560
578
  writeFileSync(nestedDuplicateGamma, '<!doctype html><html><body><h1>Gamma</h1></body></html>');
@@ -663,14 +681,28 @@ try {
663
681
  ),
664
682
  'scoped shell sed should ignore the substitution script and allow canonical target page edits'
665
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
+ );
666
697
  assert.doesNotThrow(
667
698
  () => cli._guardDirectBashCommand(
668
699
  'touch public_html/css/domain-detail.css',
669
700
  fixture.repo,
670
701
  { task: message }
671
702
  ),
672
- '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'
673
704
  );
705
+ writeFileSync(alphaFixturePath, alphaFixtureCurrent);
674
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>';
675
707
  assert.throws(
676
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 = [];
@@ -852,6 +965,24 @@ export class OpenClawCLI extends EventEmitter {
852
965
  if (!this._directTaskScopeAllowsPath(filePath, workDir, options.task)) {
853
966
  throw this._directScopeViolationError(this._formatDirectScopeError(filePath, workDir, options.task));
854
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
+ }
855
986
  if (pageOnly && slugs.length > 0 && this._isDirectHtmlPagePath(relativePath)) {
856
987
  const matchedSlugs = this._directScopeSlugsMatchingText(lowerRelativePath, slugs);
857
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
  }