#!/usr/bin/env node import { readFile, readdir } from 'node:fs/promises' import { existsSync } from 'node:fs' import { execFileSync } from 'node:child_process' import path from 'node:path' const root = process.cwd() const failures = [] function fail(message) { failures.push(message) } async function readText(relativePath) { return readFile(path.join(root, relativePath), 'utf8') } function requireFile(relativePath) { if (!existsSync(path.join(root, relativePath))) { fail(`Missing required harness file: ${relativePath}`) } } function requireDir(relativePath) { if (!existsSync(path.join(root, relativePath))) { fail(`Missing required project directory: ${relativePath}`) } } function gitLines(args) { try { return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }) .split(/\r?\n/) .map(line => line.trim()) .filter(Boolean) } catch { return [] } } function changedFilesFromGit() { const files = new Set() for (const file of gitLines(['diff', '--name-only'])) files.add(file) for (const file of gitLines(['diff', '--name-only', '--cached'])) files.add(file) for (const file of gitLines(['ls-files', '--others', '--exclude-standard'])) files.add(file) const baseRef = process.env.GITHUB_BASE_REF if (baseRef) { const baseCandidates = [`origin/${baseRef}`, baseRef] let foundPrBase = false for (const base of baseCandidates) { const diff = gitLines(['diff', '--name-only', `${base}...HEAD`]) if (diff.length > 0) { foundPrBase = true for (const file of diff) files.add(file) break } } if (process.env.GITHUB_ACTIONS === 'true' && !foundPrBase && files.size === 0) { fail(`Unable to inspect PR diff against ${baseRef}; build checkout must fetch full history`) } } else { const upstream = gitLines(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])[0] if (upstream) { for (const file of gitLines(['diff', '--name-only', `${upstream}...HEAD`])) files.add(file) } } return [...files].sort() } function isChatSessionChainFile(file) { return file === 'packages/client/src/api/hermes/chat.ts' || file === 'packages/client/src/api/hermes/group-chat.ts' || file === 'packages/client/src/api/hermes/sessions.ts' || file === 'packages/client/src/stores/hermes/group-chat.ts' || file === 'packages/client/src/stores/hermes/chat.ts' || file === 'packages/server/src/controllers/hermes/sessions.ts' || file === 'packages/server/src/db/hermes/session-store.ts' || file === 'packages/server/src/routes/hermes/group-chat.ts' || file.startsWith('packages/client/src/components/hermes/group-chat/') || file.startsWith('packages/client/src/components/hermes/chat/') || file.startsWith('packages/server/src/lib/context-compressor/') || file.startsWith('packages/server/src/services/hermes/context-engine/') || file.startsWith('packages/server/src/services/hermes/group-chat/') || file.startsWith('packages/server/src/services/hermes/run-chat/') || file.startsWith('packages/server/src/services/hermes/agent-bridge/') } function isChatChainChangeFragment(file) { return file.startsWith('docs/chat-chain-changes/') && file.endsWith('.md') && path.basename(file) !== 'README.md' } for (const file of [ 'AGENTS.md', 'ARCHITECTURE.md', 'DEVELOPMENT.md', 'docs/harness/README.md', 'docs/harness/validation.md', 'docs/harness/worktree-runbook.md', 'docs/harness/pr-review.md', 'docs/chat-chain-changes/README.md', // 构建 / 测试 / 类型检查的入口配置。缺任何一个都会让 npm run build 或 // npm run test 的行为与 CI 不一致,属于 harness 自身失效,必须先报出来。 'README.md', 'package.json', 'vite.config.ts', 'vitest.config.ts', 'playwright.config.ts', 'tsconfig.json', ]) { requireFile(file) } for (const dir of [ 'packages/client/src', 'packages/server/src', 'packages/skills', 'tests/client', 'tests/server', 'tests/e2e', '.github/workflows', 'docs/chat-chain-changes', ]) { requireDir(dir) } // 禁止目录:AGENTS.md 的 Red Lines 明确写明 packages/desktop 与 packages/website // 已删除、不得重新引入;ekko-agent 与 esp32-c3 同样是已砍掉的旧运行时/固件包。 // 这里只检查目录是否存在,不检查字符串引用——个别源码里还留着指向这些路径的 // 字符串(例如 scripts/build-server.mjs 与 mcu-firmware.ts),那些引用有 // existsSync 保护,不会导致构建失败,清理由各自负责。 // // 注意:reference_code 暂不在此列,按任务书要求留到 Phase 009 再加,避免中途误红。 for (const dir of [ 'packages/desktop', 'packages/website', 'packages/ekko-agent', 'packages/esp32-c3', ]) { if (existsSync(path.join(root, dir))) { fail(`Retired package directory must not come back: ${dir}`) } } const agents = await readText('AGENTS.md') const agentLines = agents.trimEnd().split(/\r?\n/) if (agentLines.length > 120) { fail(`AGENTS.md should stay short; found ${agentLines.length} lines, expected <= 120`) } for (const requiredLink of [ 'DEVELOPMENT.md', 'ARCHITECTURE.md', 'docs/harness/README.md', 'docs/harness/validation.md', 'docs/harness/worktree-runbook.md', 'docs/harness/pr-review.md', ]) { if (!agents.includes(requiredLink)) { fail(`AGENTS.md must link to ${requiredLink}`) } } const packageJson = JSON.parse(await readText('package.json')) for (const scriptName of [ 'harness:check', 'test', 'test:coverage', 'test:e2e', 'build', ]) { if (!packageJson.scripts?.[scriptName]) { fail(`package.json is missing script: ${scriptName}`) } } // 禁止脚本:desktop / website 两个包已删除(见上面的禁止目录检查), // 它们的构建脚本必须一起消失,否则会留下一个必然失败的 npm script, // 让人以为「跑一下 npm run build:desktop 还能用」。 const scriptNames = Object.keys(packageJson.scripts || {}) for (const forbidden of [ /^desktop:/, /^build:desktop/, /^dev:website$/, /^build:website$/, ]) { for (const scriptName of scriptNames) { if (forbidden.test(scriptName)) { fail(`package.json must not keep a script for a retired package: ${scriptName} (matched ${forbidden})`) } } } // 第二 UI 库红线:Phase 007 已把 naive-ui 全量迁移到 Arco Design Vue。 // 同时装两套 UI 库会让 CSS 体积、主题变量与组件解析产生难以定位的冲突, // 因此 package.json 里只允许出现 @arco-design/web-vue 一套。 const UI_LIBRARIES = [ '@arco-design/web-vue', 'naive-ui', 'element-plus', 'ant-design-vue', 'primevue', 'vuetify', 'quasar', ] const declaredDependencies = Object.keys({ ...packageJson.dependencies, ...packageJson.devDependencies, }) for (const dependency of declaredDependencies) { if (!UI_LIBRARIES.includes(dependency)) continue if (dependency === '@arco-design/web-vue') continue fail(`Only one UI library is allowed (@arco-design/web-vue); remove dependency: ${dependency}`) } if (!declaredDependencies.includes('@arco-design/web-vue')) { fail('package.json must depend on @arco-design/web-vue (the only allowed UI library)') } const architecture = await readText('ARCHITECTURE.md') for (const phrase of [ 'packages/client/src', 'packages/server/src', 'packages/skills', 'HERMES_WEB_UI_HOME', 'agent-bridge/python/', ]) { if (!architecture.includes(phrase)) { fail(`ARCHITECTURE.md should document: ${phrase}`) } } const buildWorkflow = await readText('.github/workflows/build.yml') if (!buildWorkflow.includes('npm run harness:check')) { fail('Build workflow must run npm run harness:check') } if (!buildWorkflow.includes('fetch-depth: 0')) { fail('Build workflow checkout must use fetch-depth: 0 so harness:check can inspect PR diffs') } const chatSessionsDoc = await readText('docs/cli-chat-sessions.md') for (const phrase of [ '最后重建时间', '维护要求', '最近链路变更记录', 'docs/chat-chain-changes/', '每个 PR 一个变更片段', 'packages/server/src/services/hermes/agent-bridge/', 'packages/server/src/services/hermes/group-chat/', 'packages/server/src/lib/context-compressor/', '任何改动都算 Chat 链路改动', ]) { if (!chatSessionsDoc.includes(phrase)) { fail(`docs/cli-chat-sessions.md must document chat chain maintenance rule: ${phrase}`) } } const changedFiles = changedFilesFromGit() const changedChatChainFiles = changedFiles.filter( file => !isChatChainChangeFragment(file) && file !== 'docs/chat-chain-changes/README.md' && file !== 'docs/cli-chat-sessions.md' && isChatSessionChainFile(file), ) const changedChatChainFragments = changedFiles.filter(isChatChainChangeFragment) if (changedChatChainFiles.length > 0 && changedChatChainFragments.length === 0) { fail( [ 'Chat session chain changed without adding a docs/chat-chain-changes/*.md fragment.', 'Add one fragment with date, PR/commit, touched feature, and behavior impact.', `Changed chain files: ${changedChatChainFiles.join(', ')}`, ].join(' '), ) } for (const file of changedChatChainFragments) { if (!existsSync(path.join(root, file))) { fail(`Chat chain change fragment was removed instead of added/updated: ${file}`) continue } const fragment = await readText(file) for (const marker of ['date:', 'feature:', 'impact:']) { if (!fragment.includes(marker)) { fail(`Chat chain change fragment ${file} must include frontmatter field: ${marker}`) } } if (!fragment.includes('pr:') && !fragment.includes('commit:')) { fail(`Chat chain change fragment ${file} must include either pr: or commit:`) } } // --- Legacy brand scan ------------------------------------------------- // The project was renamed from ekko / hermes-web-ui / hermes-studio to // hermes-ui. A few legacy identifiers are deliberately preserved so existing // deployments keep working; see ALLOWED_LEGACY_BRAND and // GLOBAL_ALLOWED_PATTERNS below for the reason each one survives. const LEGACY_BRAND_PATTERNS = [/\bekko\b/i, /hermes-web-ui/i, /hermes-studio/i] const SCAN_ROOTS = [ 'packages/client/src', 'packages/server/src', 'packages/skills', 'tests', 'scripts', 'bin', '.github', ] const SCAN_FILES = [ 'package.json', 'Dockerfile', 'docker-compose.yml', 'AGENTS.md', 'ARCHITECTURE.md', 'DEVELOPMENT.md', 'README.md', 'README_zh.md', ] const SCAN_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.vue', '.json', '.yml', '.yaml', '.md', '.py']) const SCAN_SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'reference_code', '__pycache__']) // These files necessarily spell out the patterns and allow-list entries they check. // tests/server/no-legacy-brand.test.ts is the vitest mirror of this scan: it // re-declares the same patterns and the same allow-list, so it would otherwise // report itself on every line. const SCAN_SKIP_FILES = new Set([ 'scripts/harness-check.mjs', 'tests/server/no-legacy-brand.test.ts', ]) // Every entry must state why the legacy string is still allowed. An entry with // a `pattern` exempts only the lines matching it, so a new violation elsewhere // in the same file is still reported; omit `pattern` only when every legacy // string in the file is intentional. const ALLOWED_LEGACY_BRAND = [ { file: 'AGENTS.md', pattern: /hermes-studio\.ai|global-agent relay|not affiliated/, reason: 'Red Lines warn agents not to strip the load-bearing upstream relay address, so the doc has to name it', }, { file: 'packages/server/src/db/index.ts', reason: 'DB filenames predate the rename; renamed files would orphan data in ~/.hermes-web-ui', }, { file: 'bin/hermes-ui.mjs', reason: 'CLI reads the same pre-rename DB filename as packages/server/src/db/index.ts', }, { file: 'packages/server/src/config.ts', pattern: /url: 'https:\/\/api\.hermes-studio\.ai'/, reason: 'remoteRelay is the live external global-agent relay service, not the product brand', }, { file: 'packages/server/src/middleware/user-auth.ts', reason: 'legacy JWT audience must stay accepted so upgrades do not force re-login', }, { file: 'packages/server/src/services/hermes/skill-injector.ts', reason: 'legacy manifest owner must stay recognized so managed skills are not re-injected', }, { file: 'packages/server/src/services/hermes/studio-mcp-autoinject.ts', reason: 'legacy MCP server/command names are detected to clean up old on-disk config', }, { file: 'packages/server/src/services/hermes/agent-bridge/manager.ts', pattern: /HERMES_OPENROUTER_APP_REFERER: 'https:\/\/hermes-studio\.ai'/, reason: 'OpenRouter attribution referer is the upstream app identity, not the product brand', }, { file: 'packages/client/src/components/layout/AppSidebar.vue', pattern: /href="https:\/\/hermes-studio\.ai\/"/, reason: 'sidebar footer links to the upstream product website, not the product brand', }, { file: 'tests/server/studio-mcp-autoinject.test.ts', reason: 'asserts legacy MCP entries are cleaned from existing on-disk config', }, { file: 'tests/server/mcu-login-controller.test.ts', pattern: /const remoteRelayUrl = 'https:\/\/api\.hermes-studio\.ai'/, reason: 'asserts the remoteRelay base URL configured in packages/server/src/config.ts', }, { file: 'tests/server/agent-bridge-manager.test.ts', pattern: /HERMES_OPENROUTER_APP_REFERER\)\.toBe\('https:\/\/hermes-studio\.ai'\)/, reason: 'asserts the OpenRouter referer configured in agent-bridge/manager.ts', }, { file: 'tests/server/run-chat-bridge-final-context.test.ts', pattern: /not\.toContain\('mcp__hermes-studio__'\)/, reason: 'asserts the legacy MCP tool prefix does not leak into bridge instructions', }, { file: 'tests/server/cli-port-detection.test.ts', reason: 'temp dir names and the preserved hermes-web-ui.db filename', }, { file: 'tests/server/agent-bridge-python-concurrency.test.ts', reason: 'asserts preview socket path derived from the preview endpoint env var', }, { file: 'packages/server/src/controllers/hermes/sessions.ts', reason: 'the visible-session-source check still lists coding_agent so sessions stored before Phase 003 stay listed', }, { file: 'packages/client/src/shared/session-display.ts', reason: 'SOURCE_LABELS still maps the pre-Phase-003 coding_agent source so stored sessions render a label', }, ] // Legacy identifiers that stay valid everywhere, because they are public // deployment contracts or wire-protocol values rather than project branding. const GLOBAL_ALLOWED_PATTERNS = [ /HERMES_WEB_UI[A-Z0-9_]*/, /HERMES_WEBUI_STATE_DIR/, /\.hermes-web-ui/, /hermes_brightness/, /hermes_style/, /hermes_locale/, // Global-agent relay socket role identifier. It is part of the wire contract // with the relay service, so renaming it would break cross-version peers. /role\s*(?::|===)\s*'hermes-studio'/, ] async function collectScanFiles(target, out) { const absolute = path.join(root, target) const stat = await readdir(absolute, { withFileTypes: true }).catch(() => null) if (!stat) return for (const entry of stat) { const relative = path.join(target, entry.name) if (entry.isDirectory()) { if (SCAN_SKIP_DIRS.has(entry.name)) continue await collectScanFiles(relative, out) } else if (SCAN_EXTENSIONS.has(path.extname(entry.name))) { out.push(relative) } } } function isAllowedLegacyBrand(relativePath, line) { if (GLOBAL_ALLOWED_PATTERNS.some(pattern => pattern.test(line))) return true const normalized = relativePath.split(path.sep).join('/') // An allow-list entry may name a directory, in which case it covers its files. return ALLOWED_LEGACY_BRAND.some(entry => { if (normalized !== entry.file && !normalized.startsWith(`${entry.file}/`)) return false return !entry.pattern || entry.pattern.test(line) }) } const scanFiles = [] for (const target of SCAN_ROOTS) await collectScanFiles(target, scanFiles) for (const file of SCAN_FILES) { if (existsSync(path.join(root, file))) scanFiles.push(file) } const brandHits = [] for (const relativePath of scanFiles) { if (SCAN_SKIP_FILES.has(relativePath.split(path.sep).join('/'))) continue const text = await readFile(path.join(root, relativePath), 'utf8') const lines = text.split(/\r?\n/) for (let index = 0; index < lines.length; index += 1) { const line = lines[index] if (!LEGACY_BRAND_PATTERNS.some(pattern => pattern.test(line))) continue if (isAllowedLegacyBrand(relativePath, line)) continue brandHits.push(`${relativePath}:${index + 1}: ${line.trim()}`) } } for (const hit of brandHits) { fail(`Legacy brand string must be removed (ekko / hermes-web-ui / hermes-studio): ${hit}`) } // --- Retired runtime scan ---------------------------------------------- // Phase 003 removed the coding-agent and in-house agent runtimes. Every chat // session now runs through the Hermes bridge. A few identifiers survive only // so sessions, workflows, and history rows stored before Phase 003 keep // loading; RETIRED_RUNTIME_ALLOWED records why each one stays. const RETIRED_RUNTIME_PATTERNS = [ /\bcoding_agent\b/, /\bcodingAgent[A-Za-z]/, /\bCodingAgent[A-Za-z]/, /\bekko-agent\b/, /\bekkoAgent[A-Za-z]/, /\bEkkoAgent[A-Za-z]/, ] const RETIRED_RUNTIME_ALLOWED = [ { file: 'packages/server/src/controllers/hermes/sessions.ts', reason: 'isVisibleWebUiSessionSource still accepts the retired source so sessions stored before Phase 003 stay listed', }, { file: 'packages/client/src/shared/session-display.ts', reason: 'SOURCE_LABELS still maps the retired source so stored sessions render a label', }, ] function isAllowedRetiredRuntime(relativePath, line) { // Comments explain why the retired identifier still exists; only code hits matter. const trimmed = line.trim() if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return true const normalized = relativePath.split(path.sep).join('/') return RETIRED_RUNTIME_ALLOWED.some( entry => normalized === entry.file || normalized.startsWith(`${entry.file}/`), ) } const retiredHits = [] for (const relativePath of scanFiles) { const normalized = relativePath.split(path.sep).join('/') if (SCAN_SKIP_FILES.has(normalized)) continue if (normalized.startsWith('docs/')) continue const text = await readFile(path.join(root, relativePath), 'utf8') const lines = text.split(/\r?\n/) for (let index = 0; index < lines.length; index += 1) { const line = lines[index] if (!RETIRED_RUNTIME_PATTERNS.some(pattern => pattern.test(line))) continue if (isAllowedRetiredRuntime(relativePath, line)) continue retiredHits.push(`${relativePath}:${index + 1}: ${line.trim()}`) } } for (const hit of retiredHits) { fail(`Retired runtime identifier must be removed (coding-agent / ekko-agent): ${hit}`) } if (failures.length > 0) { console.error('Harness check failed:') for (const failure of failures) { console.error(`- ${failure}`) } process.exit(1) } console.log('Harness check passed')