🎉 init: 小龙的工作空间
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* audit-agents.mjs — AGENTS.md Execution Protocol Audit
|
||||
*
|
||||
* Checks:
|
||||
* 1. Task Tree exists (§1)
|
||||
* 2. Agent Package isolation requirements
|
||||
* 3. No XL Package (context budget forbids it)
|
||||
* 4. Injected Risks mechanism (§1e / §3b)
|
||||
* 5. Quality Gate completeness (§5)
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/audit-agents.mjs ← standalone
|
||||
* import { runAgentAudit } from './audit-agents.mjs' ← via orchestrator
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const WORKSPACE = path.resolve(__dirname, '..');
|
||||
const SELF = process.argv[1].replace(/\\/g, '/');
|
||||
|
||||
/**
|
||||
* @typedef {Object} CheckResult
|
||||
* @property {string} id
|
||||
* @property {string} name
|
||||
* @property {'PASS'|'WARN'|'FAIL'} status
|
||||
* @property {string} evidence
|
||||
* @property {string} [suggestion]
|
||||
*/
|
||||
|
||||
/** @returns {CheckResult[]} */
|
||||
export function runAgentAudit(workspaceDir) {
|
||||
const root = workspaceDir || WORKSPACE;
|
||||
const checks = [];
|
||||
|
||||
// ---- Helper ----
|
||||
function readAgents() {
|
||||
const p = path.join(root, 'AGENTS.md');
|
||||
if (!fs.existsSync(p)) return null;
|
||||
return fs.readFileSync(p, 'utf8');
|
||||
}
|
||||
|
||||
function check(prefix, id, name, condition, evidence, suggestion) {
|
||||
checks.push({
|
||||
id: `${prefix}-${id}`,
|
||||
name,
|
||||
status: condition ? 'PASS' : 'FAIL',
|
||||
evidence: condition ? evidence : `File found but missing: ${evidence}`,
|
||||
...(condition ? {} : { suggestion: suggestion || `Ensure §${id} is properly defined in AGENTS.md` }),
|
||||
});
|
||||
}
|
||||
|
||||
function warn(prefix, id, name, evidence, suggestion) {
|
||||
checks.push({
|
||||
id: `${prefix}-${id}`,
|
||||
name,
|
||||
status: 'WARN',
|
||||
evidence,
|
||||
suggestion,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Read AGENTS.md ----
|
||||
const agents = readAgents();
|
||||
if (!agents) {
|
||||
checks.push({
|
||||
id: 'AG-001',
|
||||
name: 'AGENTS.md exists',
|
||||
status: 'FAIL',
|
||||
evidence: 'AGENTS.md not found in workspace root',
|
||||
suggestion: 'Create AGENTS.md with the Execution Protocol',
|
||||
});
|
||||
return checks;
|
||||
}
|
||||
checks.push({
|
||||
id: 'AG-001',
|
||||
name: 'AGENTS.md exists',
|
||||
status: 'PASS',
|
||||
evidence: `AGENTS.md found (${agents.length} bytes)`,
|
||||
});
|
||||
|
||||
// ---- §0 Complexity Classification ----
|
||||
const hasClassify = /###\s*0\.\s*Complexity Classification/.test(agents);
|
||||
check('AG', '0', '§0 Complexity Classification defined', hasClassify,
|
||||
'Found "### 0. Complexity Classification" with Simple/Complex criteria',
|
||||
'Add "### 0. Complexity Classification" with Simple→Direct / Complex→Task Tree decision rules');
|
||||
|
||||
// Detect if the "if uncertain → treat as Complex" rule exists
|
||||
const hasUncertainRule = /if uncertain → treat as Complex/i.test(agents) || /if uncertain.*complex/i.test(agents);
|
||||
check('AG', '0b', '§0 Uncertainty fallback ("if uncertain → treat as Complex")', hasUncertainRule,
|
||||
'Uncertainty fallback rule found',
|
||||
'Add decision rule: "if uncertain → treat as Complex"');
|
||||
|
||||
// ---- §1 Task Tree ----
|
||||
const hasTaskTree = /###\s*1\.\s*Task Tree/.test(agents);
|
||||
check('AG', '1', '§1 Task Tree Construction defined', hasTaskTree,
|
||||
'Found "### 1. Task Tree Construction" with Task Tree definition',
|
||||
'Add "### 1. Task Tree Construction" defining the execution tree');
|
||||
|
||||
// Task Tree Quality Gate (7 checks)
|
||||
const hasTaskTreeGate = /Task Tree Quality Gate/.test(agents);
|
||||
check('AG', '1f', '§1f Task Tree Quality Gate (7 checks)', hasTaskTreeGate,
|
||||
'Found Task Tree Quality Gate with 7 verification items',
|
||||
'Add §1f Quality Gate checklist (Root Task / No orphan / No circular / No XL / No unjust serial / Risks populated / No human dep)');
|
||||
|
||||
// ---- §2 PLAN ----
|
||||
const hasPlan = /###\s*2\.\s*Planning Protocol/.test(agents);
|
||||
check('AG', '2', '§2 Planning Protocol defined', hasPlan,
|
||||
'Found "### 2. Planning Protocol" with Goal/Subtasks/Risks/Verify',
|
||||
'Add "### 2. Planning Protocol" section');
|
||||
|
||||
// ---- §3 Pre-flight ----
|
||||
const hasPreflight = /###\s*3\.\s*Pre-flight/.test(agents);
|
||||
check('AG', '3', '§3 Pre-flight Check defined', hasPreflight,
|
||||
'Found "### 3. Pre-flight Check" with Dependency Scan / Learning Context / Domain Checklists',
|
||||
'Add "### 3. Pre-flight Check" section');
|
||||
|
||||
// 3a Dependency Scan
|
||||
const hasDepScan = /####\s*3a\.\s*Dependency Scan/.test(agents);
|
||||
check('AG', '3a', '§3a Dependency Scan defined', hasDepScan,
|
||||
'Found §3a Dependency Scan',
|
||||
'Add §3a Dependency Scan (files/tables, dependencies, blast radius)');
|
||||
|
||||
// 3b Learning Context Injector
|
||||
const hasLearningCtx = /####\s*3b\.\s*Learning Context Injector/.test(agents);
|
||||
check('AG', '3b', '§3b Learning Context Injector (3 searches)', hasLearningCtx,
|
||||
'Found §3b Learning Context Injector with 3 mandatory searches',
|
||||
'Add §3b with 3 searches: similar failures, best practices, playbooks');
|
||||
|
||||
// 3c Domain Checklists
|
||||
const hasDomainChecklists = /####\s*3c\.\s*Domain Checklist Injection/.test(agents);
|
||||
check('AG', '3c', '§3c Domain Checklists with ⚠️ Hard Rules', hasDomainChecklists,
|
||||
'Found §3c Domain Checklist Injection with SQL/JS/Next.js/Backend/Generator/Release checklists',
|
||||
'Add §3c with at least SQL, JS/TS, Next.js, Backend, Generator, Release checklists');
|
||||
|
||||
// Hard Rules exist
|
||||
const hardRuleCount = (agents.match(/⚠️\s*HARD RULE/g) || []).length;
|
||||
warn('AG', '3c-hard', `§3c Hard Rules count: ${hardRuleCount} (target: ≥5)`,
|
||||
`Found ${hardRuleCount} ⚠️ HARD RULE markers`,
|
||||
'Promote recurring patterns to Hard Rules. Target ≥5.');
|
||||
|
||||
// ---- §4 Failure Replanning ----
|
||||
const hasReplan = /###\s*4\.\s*Failure Replanning Protocol/.test(agents);
|
||||
check('AG', '4', '§4 Failure Replanning Protocol defined', hasReplan,
|
||||
'Found "### 4. Failure Replanning Protocol" with local/structural replan logic',
|
||||
'Add "### 4. Failure Replanning Protocol"');
|
||||
|
||||
// ---- §5 Verification Protocol ----
|
||||
const hasVerification = /###\s*5\.\s*Verification Protocol/.test(agents);
|
||||
check('AG', '5', '§5 Verification Protocol defined', hasVerification,
|
||||
'Found "### 5. Verification Protocol" with Build/Tests/Lint/Regression/Manual checks',
|
||||
'Add "### 5. Verification Protocol" with the verification block format');
|
||||
|
||||
// Quality Gate in Verification
|
||||
const hasQualityGate = /Pre-flight Compliance Check/.test(agents) || /Quality Gate/.test(agents);
|
||||
check('AG', '5b', '§5 Pre-flight Compliance / Quality Gate', hasQualityGate,
|
||||
'Found "Pre-flight Compliance Check" with 5 mandatory gates',
|
||||
'Add Quality Gate checklist within §5: PLAN has Injected Risks, ≥2 sources searched, domain checklist, reflection, 7-field reflection');
|
||||
|
||||
// ---- §6 Governance Compliance ----
|
||||
const hasGovernance = /###\s*6\.\s*Governance Compliance/.test(agents);
|
||||
check('AG', '6', '§6 Governance Compliance defined', hasGovernance,
|
||||
'Found "### 6. Governance Compliance" with State Machine / Heartbeat / Quality Gate',
|
||||
'Add "### 6. Governance Compliance" referencing GOVERNANCE.md');
|
||||
|
||||
// ---- §7 Auto-Capture ----
|
||||
const hasAutoCapture = /###\s*7\.\s*Auto-Capture Trigger/.test(agents);
|
||||
check('AG', '7', '§7 Auto-Capture Trigger defined', hasAutoCapture,
|
||||
'Found "### 7. Auto-Capture Trigger" with T1-T7 trigger conditions',
|
||||
'Add "### 7. Auto-Capture Trigger" with 7 trigger conditions and skip rules');
|
||||
|
||||
// ---- Learning Loop ----
|
||||
const hasLearningLoop = /## Learning Loop/.test(agents);
|
||||
check('AG', 'LL', 'Learning Loop (🔄) defined', hasLearningLoop,
|
||||
'Found "## Learning Loop 🔄" with Loop Architecture / Promotion Rules / Reflection Quality Gate / Storage Locations',
|
||||
'Add "## Learning Loop 🔄" section');
|
||||
|
||||
// Promotion Rules (1→2→3→4→5)
|
||||
const hasPromotion = /Promotion Rules/.test(agents) && /1st.*Reflection/.test(agents) && /5th.*Hard Rule/.test(agents);
|
||||
check('AG', 'LL-promo', 'Promotion Rules (1→2→3→4→5 appearances)', hasPromotion,
|
||||
'Found Promotion Rules chart with 1st=Reflection through 5th=Hard Rule',
|
||||
'Add Promotion Rules table mapping appearance count to promotion destination');
|
||||
|
||||
// ---- Context Budget ----
|
||||
const hasBudget = /## Agent Package Context Budget/.test(agents);
|
||||
check('AG', 'CB', 'Agent Package Context Budget defined', hasBudget,
|
||||
'Found "## Agent Package Context Budget ⚠️ HARD RULE" with size→token map',
|
||||
'Add "## Agent Package Context Budget" section');
|
||||
|
||||
// No XL
|
||||
const forbidsXL = /XL.*FORBIDDEN/.test(agents) || /XL.*❌/.test(agents);
|
||||
check('AG', 'CB-noxl', 'XL Package explicitly forbidden', forbidsXL,
|
||||
'Found "XL | ❌ FORBIDDEN" in Context Budget table',
|
||||
'Add "XL | ❌ FORBIDDEN" row to the Size→Token Budget Mapping table');
|
||||
|
||||
// Input Size Pre-check
|
||||
const hasPrecheck = /Input Size Pre-check/.test(agents);
|
||||
check('AG', 'CB-precheck', 'Input Size Pre-check (Context Budget Declaration)', hasPrecheck,
|
||||
'Found "Input Size Pre-check" with Context Budget Declaration template',
|
||||
'Add "Input Size Pre-check" section with file inventory / token estimate / budget check');
|
||||
|
||||
// ---- Cross-reference integrity ----
|
||||
const refsGovernance = (agents.match(/GOVERNANCE\.md/g) || []).length;
|
||||
warn('AG', 'XR-gov', `Cross-references to GOVERNANCE.md: ${refsGovernance} (target: ≥3)`,
|
||||
`Found ${refsGovernance} references to GOVERNANCE.md`,
|
||||
'Add cross-references from AGENTS.md to GOVERNANCE.md in §6');
|
||||
|
||||
const refsPortfolio = (agents.match(/PORTFOLIO\.md/g) || []).length;
|
||||
warn('AG', 'XR-port', `Cross-references to PORTFOLIO.md: ${refsPortfolio} (target: ≥1)`,
|
||||
`Found ${refsPortfolio} references to PORTFOLIO.md`,
|
||||
'Add cross-reference to PORTFOLIO.md in §6e Reference section');
|
||||
|
||||
// ---- Injected Risks (target check) ----
|
||||
const hasInjectedRisks = /Injected Risks/.test(agents);
|
||||
check('AG', 'IR', 'Injected Risks mechanism defined', hasInjectedRisks,
|
||||
'Found "### Injected Risks" / "### Per-Task Injected Risks"',
|
||||
'Add "### Injected Risks (from Learning Context)" format in §1e and §3b');
|
||||
|
||||
// ---- Summary ----
|
||||
const passCount = checks.filter(c => c.status === 'PASS').length;
|
||||
const warnCount = checks.filter(c => c.status === 'WARN').length;
|
||||
const failCount = checks.filter(c => c.status === 'FAIL').length;
|
||||
checks.push({
|
||||
id: 'AG-SUMMARY',
|
||||
name: `Summary — ${passCount} PASS / ${warnCount} WARN / ${failCount} FAIL`,
|
||||
status: failCount > 0 ? 'FAIL' : warnCount > 0 ? 'WARN' : 'PASS',
|
||||
evidence: `${passCount} passed, ${warnCount} warnings, ${failCount} failures`,
|
||||
});
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
// ---- Standalone runner ----
|
||||
if (SELF.endsWith('/audit-agents.mjs') || SELF.endsWith('\\audit-agents.mjs')) {
|
||||
const results = runAgentAudit(WORKSPACE);
|
||||
console.log(`\n=== AGENTS.md Audit ===\n`);
|
||||
for (const r of results) {
|
||||
const icon = r.status === 'PASS' ? '✅' : r.status === 'WARN' ? '⚠️' : '❌';
|
||||
console.log(` ${icon} [${r.id}] ${r.name}`);
|
||||
console.log(` ${r.evidence}`);
|
||||
if (r.suggestion) console.log(` → ${r.suggestion}`);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* audit-all.mjs — Three-Layer Protocol Audit Orchestrator
|
||||
*
|
||||
* Runs all three protocol audits sequentially:
|
||||
* 1. AGENTS.md — Execution Protocol
|
||||
* 2. GOVERNANCE.md — Execution Governance
|
||||
* 3. PORTFOLIO.md — Portfolio Management
|
||||
*
|
||||
* Outputs:
|
||||
* - Console: colorized summary with PASS / WARN / FAIL
|
||||
* - File: audit-report.md (generated in workspace root)
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/audit-all.mjs
|
||||
*
|
||||
* npm script (add to package.json):
|
||||
* "audit": "node scripts/audit-all.mjs"
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const WORKSPACE = path.resolve(__dirname, '..');
|
||||
|
||||
// Dynamic imports for the three audit modules
|
||||
const [agentsMod, govMod, portMod] = await Promise.all([
|
||||
import(path.join(__dirname, 'audit-agents.mjs')),
|
||||
import(path.join(__dirname, 'audit-governance.mjs')),
|
||||
import(path.join(__dirname, 'audit-portfolio.mjs')),
|
||||
]);
|
||||
|
||||
/** @returns {{ layer: string, checks: import('./audit-agents.mjs').CheckResult[] }[]} */
|
||||
function runAll() {
|
||||
const layers = [
|
||||
{ layer: 'Execution Protocol (AGENTS.md)', fn: () => agentsMod.runAgentAudit(WORKSPACE) },
|
||||
{ layer: 'Execution Governance (GOVERNANCE.md)', fn: () => govMod.runGovernanceAudit(WORKSPACE) },
|
||||
{ layer: 'Portfolio Management (PORTFOLIO.md)', fn: () => portMod.runPortfolioAudit(WORKSPACE) },
|
||||
];
|
||||
return layers.map(({ layer, fn }) => ({ layer, checks: fn() }));
|
||||
}
|
||||
|
||||
/** Colorized console output */
|
||||
function printConsole(results) {
|
||||
let totalPass = 0, totalWarn = 0, totalFail = 0, totalChecks = 0;
|
||||
|
||||
for (const { layer, checks } of results) {
|
||||
// Skip summary rows for individual counts
|
||||
const realChecks = checks.filter(c => !c.id.endsWith('-SUMMARY'));
|
||||
const pass = realChecks.filter(c => c.status === 'PASS').length;
|
||||
const warn = realChecks.filter(c => c.status === 'WARN').length;
|
||||
const fail = realChecks.filter(c => c.status === 'FAIL').length;
|
||||
totalPass += pass;
|
||||
totalWarn += warn;
|
||||
totalFail += fail;
|
||||
totalChecks += realChecks.length;
|
||||
|
||||
const overall = fail > 0 ? '\x1b[31mFAIL\x1b[0m' : warn > 0 ? '\x1b[33mWARN\x1b[0m' : '\x1b[32mPASS\x1b[0m';
|
||||
console.log(`\n\x1b[1m${'='.repeat(60)}\x1b[0m`);
|
||||
console.log(`\x1b[1m ${layer}\x1b[0m ${overall}`);
|
||||
console.log(`\x1b[1m${'='.repeat(60)}\x1b[0m`);
|
||||
|
||||
for (const r of checks) {
|
||||
if (r.id.endsWith('-SUMMARY')) continue;
|
||||
const icon = r.status === 'PASS' ? '\x1b[32m✅\x1b[0m' : r.status === 'WARN' ? '\x1b[33m⚠️\x1b[0m' : '\x1b[31m❌\x1b[0m';
|
||||
const statusTag = r.status === 'PASS' ? '\x1b[32mPASS\x1b[0m' : r.status === 'WARN' ? '\x1b[33mWARN\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` ${icon} [${statusTag}] [${r.id}] ${r.name}`);
|
||||
console.log(` ${r.evidence}`);
|
||||
if (r.suggestion) console.log(` \x1b[90m→ ${r.suggestion}\x1b[0m`);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
// Final summary
|
||||
console.log(`\x1b[1m${'='.repeat(60)}\x1b[0m`);
|
||||
console.log(`\x1b[1m THREE-LAYER AUDIT COMPLETE\x1b[0m`);
|
||||
console.log(`\x1b[1m${'='.repeat(60)}\x1b[0m`);
|
||||
console.log(` Total checks: ${totalChecks}`);
|
||||
console.log(` \x1b[32mPASS: ${totalPass}\x1b[0m`);
|
||||
console.log(` \x1b[33mWARN: ${totalWarn}\x1b[0m`);
|
||||
console.log(` \x1b[31mFAIL: ${totalFail}\x1b[0m`);
|
||||
|
||||
const verdict = totalFail > 0 ? '\x1b[31mFAIL' : totalWarn > 0 ? '\x1b[33mWARN' : '\x1b[32mPASS';
|
||||
console.log(`\n VERDICT: ${verdict}\x1b[0m`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
/** Generate audit-report.md */
|
||||
function writeReport(results) {
|
||||
const reportPath = path.join(WORKSPACE, 'audit-report.md');
|
||||
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' +08:00';
|
||||
let md = `# Audit Report — ${timestamp}\n\n`;
|
||||
md += `> Generated by \`scripts/audit-all.mjs\`\n\n`;
|
||||
md += `## Global Summary\n\n`;
|
||||
|
||||
let totalPass = 0, totalWarn = 0, totalFail = 0, totalChecks = 0;
|
||||
|
||||
// First pass: aggregate all checks across layers
|
||||
const allChecksFlat = [];
|
||||
for (const { layer, checks } of results) {
|
||||
for (const r of checks) {
|
||||
if (r.id.endsWith('-SUMMARY')) continue;
|
||||
allChecksFlat.push({ ...r, layer });
|
||||
if (r.status === 'PASS') totalPass++;
|
||||
else if (r.status === 'WARN') totalWarn++;
|
||||
else if (r.status === 'FAIL') totalFail++;
|
||||
totalChecks++;
|
||||
}
|
||||
}
|
||||
|
||||
md += `| Metric | Value |\n|--------|-------|\n`;
|
||||
md += `| Total Checks | ${totalChecks} |\n`;
|
||||
md += `| ✅ PASS | ${totalPass} |\n`;
|
||||
md += `| ⚠️ WARN | ${totalWarn} |\n`;
|
||||
md += `| ❌ FAIL | ${totalFail} |\n`;
|
||||
md += `| **Verdict** | **${totalFail > 0 ? '❌ FAIL' : totalWarn > 0 ? '⚠️ WARN' : '✅ PASS'}** |\n\n`;
|
||||
|
||||
const verdictOverall = totalFail > 0 ? 'FAIL' : totalWarn > 0 ? 'WARN' : 'PASS';
|
||||
md += `**Verdict: ${verdictOverall}**\n\n`;
|
||||
|
||||
// Layer-by-layer tables
|
||||
for (const { layer, checks } of results) {
|
||||
const real = checks.filter(c => !c.id.endsWith('-SUMMARY'));
|
||||
const lp = real.filter(c => c.status === 'PASS').length;
|
||||
const lw = real.filter(c => c.status === 'WARN').length;
|
||||
const lf = real.filter(c => c.status === 'FAIL').length;
|
||||
const lv = lf > 0 ? '❌ FAIL' : lw > 0 ? '⚠️ WARN' : '✅ PASS';
|
||||
md += `## ${layer} — ${lv}\n\n`;
|
||||
md += `| ID | Name | Status | Evidence | Suggestion |\n`;
|
||||
md += `|----|------|--------|----------|------------|\n`;
|
||||
|
||||
for (const r of real) {
|
||||
const icon = r.status === 'PASS' ? '✅' : r.status === 'WARN' ? '⚠️' : '❌';
|
||||
const sug = r.suggestion ? r.suggestion.replace(/\|/g, '\\|') : '';
|
||||
const ev = r.evidence.replace(/\|/g, '\\|').replace(/\n/g, ' ').slice(0, 120);
|
||||
md += `| ${r.id} | ${r.name} | ${icon} ${r.status} | ${ev} | ${sug} |\n`;
|
||||
}
|
||||
|
||||
md += `| **Layer Summary** | **${lp} PASS / ${lw} WARN / ${lf} FAIL** | **${lv}** | | |\n\n`;
|
||||
}
|
||||
|
||||
// Fails list
|
||||
const fails = allChecksFlat.filter(c => c.status === 'FAIL');
|
||||
if (fails.length > 0) {
|
||||
md += `## ❌ Failures (repair required)\n\n`;
|
||||
for (const f of fails) {
|
||||
md += `- **[${f.id}]** ${f.name} — ${f.evidence}\n`;
|
||||
if (f.suggestion) md += ` - *Repair:* ${f.suggestion}\n`;
|
||||
}
|
||||
md += '\n';
|
||||
}
|
||||
|
||||
// Warnings list
|
||||
const warns = allChecksFlat.filter(c => c.status === 'WARN');
|
||||
if (warns.length > 0) {
|
||||
md += `## ⚠️ Warnings (improvement recommended)\n\n`;
|
||||
for (const w of warns) {
|
||||
md += `- **[${w.id}]** ${w.name} — ${w.evidence}\n`;
|
||||
if (w.suggestion) md += ` - *Suggestion:* ${w.suggestion}\n`;
|
||||
}
|
||||
md += '\n';
|
||||
}
|
||||
|
||||
// Coverage summary
|
||||
md += `## Coverage\n\n`;
|
||||
md += `| Layer | Sections | Checks |\n|-------|----------|--------|\n`;
|
||||
const agentsReal = results[0].checks.filter(c => !c.id.endsWith('-SUMMARY'));
|
||||
const govReal = results[1].checks.filter(c => !c.id.endsWith('-SUMMARY'));
|
||||
const portReal = results[2].checks.filter(c => !c.id.endsWith('-SUMMARY'));
|
||||
md += `| AGENTS.md | §0-§7, Learning Loop, Context Budget | ${agentsReal.length} |\n`;
|
||||
md += `| GOVERNANCE.md | §1-§12 | ${govReal.length} |\n`;
|
||||
md += `| PORTFOLIO.md | §1-§14 | ${portReal.length} |\n`;
|
||||
md += `| **Total** | **3 layers** | **${totalChecks}** |\n\n`;
|
||||
|
||||
md += `---\n*Audit complete. ${timestamp}*\n`;
|
||||
|
||||
fs.writeFileSync(reportPath, md, 'utf8');
|
||||
console.log(`\n 📄 Report written to: audit-report.md`);
|
||||
return reportPath;
|
||||
}
|
||||
|
||||
// ---- Main ----
|
||||
console.log(`\x1b[1m\x1b[36m`);
|
||||
console.log(` ╔══════════════════════════════════════════════════╗`);
|
||||
console.log(` ║ THREE-LAYER PROTOCOL AUDIT ║`);
|
||||
console.log(` ║ AGENTS.md · GOVERNANCE.md · PORTFOLIO.md ║`);
|
||||
console.log(` ╚══════════════════════════════════════════════════╝`);
|
||||
console.log(`\x1b[0m`);
|
||||
|
||||
const results = runAll();
|
||||
printConsole(results);
|
||||
writeReport(results);
|
||||
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* audit-governance.mjs — GOVERNANCE.md Execution Governance Audit
|
||||
*
|
||||
* Checks:
|
||||
* 1. All task states are legal (7-state machine)
|
||||
* 2. State transitions are legal (no skipping)
|
||||
* 3. Stale task detection rules exist
|
||||
* 4. Blocker management (blocker.md template + schema)
|
||||
* 5. Execution audit exists (execution-audit.md template)
|
||||
* 6. Project Health Score formula defined
|
||||
* 7. Auto-Stop Rule defined
|
||||
* 8. Governance Quality Gate defined
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/audit-governance.mjs ← standalone
|
||||
* import { runGovernanceAudit } from './audit-governance.mjs' ← via orchestrator
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const WORKSPACE = path.resolve(__dirname, '..');
|
||||
const SELF = process.argv[1].replace(/\\/g, '/');
|
||||
|
||||
/**
|
||||
* @typedef {Object} CheckResult
|
||||
* @property {string} id
|
||||
* @property {string} name
|
||||
* @property {'PASS'|'WARN'|'FAIL'} status
|
||||
* @property {string} evidence
|
||||
* @property {string} [suggestion]
|
||||
*/
|
||||
|
||||
/** @returns {CheckResult[]} */
|
||||
export function runGovernanceAudit(workspaceDir) {
|
||||
const root = workspaceDir || WORKSPACE;
|
||||
const checks = [];
|
||||
|
||||
// ---- Helpers ----
|
||||
function readFile(rel) {
|
||||
const p = path.join(root, rel);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
return fs.readFileSync(p, 'utf8');
|
||||
}
|
||||
|
||||
function check(prefix, id, name, ok, passEv, failEv, suggestion) {
|
||||
checks.push({
|
||||
id: `${prefix}-${id}`,
|
||||
name,
|
||||
status: ok ? 'PASS' : 'FAIL',
|
||||
evidence: ok ? passEv : failEv,
|
||||
...(ok ? {} : { suggestion: suggestion || `Implement ${id} in GOVERNANCE.md` }),
|
||||
});
|
||||
}
|
||||
|
||||
function warn(prefix, id, name, evidence, suggestion) {
|
||||
checks.push({ id: `${prefix}-${id}`, name, status: 'WARN', evidence, suggestion });
|
||||
}
|
||||
|
||||
// ---- Read protocol file ----
|
||||
const gov = readFile('GOVERNANCE.md');
|
||||
if (!gov) {
|
||||
checks.push({
|
||||
id: 'GV-001',
|
||||
name: 'GOVERNANCE.md exists',
|
||||
status: 'FAIL',
|
||||
evidence: 'GOVERNANCE.md not found in workspace root',
|
||||
suggestion: 'Create GOVERNANCE.md with the Execution Governance Protocol',
|
||||
});
|
||||
return checks;
|
||||
}
|
||||
checks.push({
|
||||
id: 'GV-001',
|
||||
name: 'GOVERNANCE.md exists',
|
||||
status: 'PASS',
|
||||
evidence: `GOVERNANCE.md found (${gov.length} bytes)`,
|
||||
});
|
||||
|
||||
// ---- §1 State Machine ----
|
||||
const hasStateMachine = /§1 State Machine/.test(gov);
|
||||
check('GV', '1', '§1 State Machine defined', hasStateMachine,
|
||||
'Found "§1 State Machine" section',
|
||||
'Add §1 State Machine section');
|
||||
|
||||
// 7 states
|
||||
const states = ['NOT_STARTED', 'IN_PROGRESS', 'BLOCKED', 'WAITING_DEPENDENCY', 'READY_FOR_REVIEW', 'VERIFIED', 'FAILED', 'ARCHIVED'];
|
||||
const foundStates = states.filter(s => gov.includes(s));
|
||||
check('GV', '1a', `§1a ${states.length} states defined (found ${foundStates.length})`, foundStates.length >= states.length,
|
||||
`All ${states.length} states present: ${foundStates.join(', ')}`,
|
||||
`Missing states: ${states.filter(s => !gov.includes(s)).join(', ')}`,
|
||||
'Add missing states to §1.1 Allowed States');
|
||||
|
||||
// Mermaid diagram
|
||||
const hasMermaid = /stateDiagram-v2/.test(gov);
|
||||
check('GV', '1b', '§1b State transition diagram (mermaid)', hasMermaid,
|
||||
'Found mermaid stateDiagram-v2 with all legal transitions',
|
||||
'Add a mermaid stateDiagram-v2 for the state machine');
|
||||
|
||||
// Transition table
|
||||
const hasTransitionTable = /Transition Table/.test(gov) || /§1\.3/.test(gov);
|
||||
check('GV', '1c', '§1c Transition table (From/To/Condition)', hasTransitionTable,
|
||||
'Found Transition Table with From/To/Condition columns',
|
||||
'Add Transition Table (From | To | Condition)');
|
||||
|
||||
// Prohibited transitions
|
||||
const hasProhibited = /Prohibited Transitions/.test(gov) || /§1\.4/.test(gov);
|
||||
check('GV', '1d', '§1d Prohibited transitions defined', hasProhibited,
|
||||
'Found "Prohibited Transitions" with ❌ markers for illegal jumps',
|
||||
'Add "Prohibited Transitions" with explicit ❌ transition violations');
|
||||
|
||||
// ---- §2 Heartbeat Rule ----
|
||||
const hasHeartbeat = /§2 Heartbeat Rule/.test(gov);
|
||||
check('GV', '2', '§2 Heartbeat Rule defined', hasHeartbeat,
|
||||
'Found "§2 Heartbeat Rule" section',
|
||||
'Add §2 Heartbeat Rule section');
|
||||
|
||||
// Heartbeat format defined
|
||||
const hasHeartbeatFormat = /"ts".*"status".*"completed".*"remaining".*"next_action"/.test(gov);
|
||||
check('GV', '2a', '§2a Heartbeat JSON format defined (ts/status/completed/remaining/next_action)',
|
||||
hasHeartbeatFormat,
|
||||
'Heartbeat JSON format found with ts, status, completed, remaining, next_action fields',
|
||||
'Define heartbeat format as JSON with ts/status/completed/remaining/next_action');
|
||||
|
||||
// Stale thresholds
|
||||
const hasStaleThresholds = /Small[\s\S]*3[00][\s\S]*Medium[\s\S]*2[\s\S]*Large[\s\S]*8/.test(gov);
|
||||
check('GV', '2b', '§2b Stale thresholds defined (Small=30m, Medium=2h, Large=8h)',
|
||||
hasStaleThresholds,
|
||||
'Found stale thresholds: Small=30min/30分钟, Medium=2h/2小时, Large=8h/8小时',
|
||||
'Add Stale thresholds: Small=30min, Medium=2h, Large=8h');
|
||||
|
||||
// progress.log template exists
|
||||
const progressTmpl = readFile('scripts/templates/progress.log.template');
|
||||
check('GV', '2c', 'progress.log.template exists', progressTmpl !== null,
|
||||
'Found scripts/templates/progress.log.template',
|
||||
'Create scripts/templates/progress.log.template');
|
||||
|
||||
// ---- §3 Blocker Management ----
|
||||
const hasBlockerMgmt = /§3 Blocker Management/.test(gov);
|
||||
check('GV', '3', '§3 Blocker Management defined', hasBlockerMgmt,
|
||||
'Found "§3 Blocker Management" section',
|
||||
'Add §3 Blocker Management section');
|
||||
|
||||
// Blocker schema
|
||||
const hasBlockerSchema = /blocker[\s\S]*root_cause[\s\S]*owner[\s\S]*mitigation[\s\S]*dependency[\s\S]*created_at/.test(gov) || /protocol-reference\/templates\.md[\s\S]*blocker/.test(gov);
|
||||
check('GV', '3a', '§3a Blocker schema (blocker/root_cause/owner/mitigation/dependency/created_at)',
|
||||
hasBlockerSchema,
|
||||
'Blocker schema found in GOVERNANCE.md or protocol-reference/templates.md',
|
||||
'Define blocker md schema in protocol-reference/templates.md');
|
||||
|
||||
// Blocker escalation
|
||||
const hasBlockerEsc = /Blocker Escalation/.test(gov) || /escalated/.test(gov);
|
||||
check('GV', '3b', '§3b Blocker escalation defined', hasBlockerEsc,
|
||||
'Found Blocker Escalation mechanism with escalated: YES/NO',
|
||||
'Add Blocker Escalation: if escalated=YES, notify user and stop AP');
|
||||
|
||||
// blocker.md template exists
|
||||
const blockerTmpl = readFile('scripts/templates/blocker.md.template');
|
||||
check('GV', '3c', 'blocker.md.template exists', blockerTmpl !== null,
|
||||
'Found scripts/templates/blocker.md.template',
|
||||
'Create scripts/templates/blocker.md.template');
|
||||
|
||||
// ---- §4 Replanning Trigger ----
|
||||
const hasReplan = /§4 Replanning Trigger/.test(gov);
|
||||
check('GV', '4', '§4 Replanning Trigger defined', hasReplan,
|
||||
'Found "§4 Replanning Trigger" with R1-R8 conditions',
|
||||
'Add §4 Replanning Trigger with R1-R8 conditions');
|
||||
|
||||
// 8 trigger conditions
|
||||
const triggerCount = (gov.match(/R\d /g) || []).length;
|
||||
warn('GV', '4a', `§4a Replan triggers: ${triggerCount} (target: ≥8)`,
|
||||
`Found ${triggerCount} replan trigger conditions (R1-R? )`,
|
||||
'Ensure all 8 replan triggers (R1-R8) are defined');
|
||||
|
||||
// Replan log entry format
|
||||
const hasReplanLog = /Replan.*#N/.test(gov) || /replan.*evidence/.test(gov);
|
||||
check('GV', '4b', '§4b Replan log entry format defined', hasReplanLog,
|
||||
'Found Replan log entry format with trigger/cause/before/after/impact',
|
||||
'Define Replan log entry format (trigger, cause, before, after, impact)');
|
||||
|
||||
// ---- §5 Scope Explosion ----
|
||||
const hasScope = /§5 Scope Explosion/.test(gov);
|
||||
check('GV', '5', '§5 Scope Explosion Detector defined', hasScope,
|
||||
'Found "§5 Scope Explosion Detector" with S1-S4 detection rules',
|
||||
'Add §5 Scope Explosion Detector with S1-S4 detection rules');
|
||||
|
||||
// ---- §6 Execution Audit ----
|
||||
const hasAudit = /§6 Execution Audit/.test(gov);
|
||||
check('GV', '6', '§6 Execution Audit defined', hasAudit,
|
||||
'Found "§6 Execution Audit" section',
|
||||
'Add §6 Execution Audit section');
|
||||
|
||||
// execution-audit.md template exists
|
||||
const auditTmpl = readFile('scripts/templates/execution-audit.md.template');
|
||||
check('GV', '6a', 'execution-audit.md.template exists', auditTmpl !== null,
|
||||
'Found scripts/templates/execution-audit.md.template',
|
||||
'Create scripts/templates/execution-audit.md.template');
|
||||
|
||||
// ---- §7 Health Score ----
|
||||
const hasHealth = /§7 Project Health Score/.test(gov);
|
||||
check('GV', '7', '§7 Project Health Score defined', hasHealth,
|
||||
'Found "§7 Project Health Score" with 5 dimensions (Delivery/Quality/Parallelism/Dependency/Learning)',
|
||||
'Add §7 Project Health Score with 5 dimensions');
|
||||
|
||||
// All 5 sub-scores
|
||||
const hasDelivery = /Delivery Score/.test(gov);
|
||||
const hasQuality = /Quality Score/.test(gov);
|
||||
const hasParallelism = /Parallelism Score/.test(gov);
|
||||
const hasDependency = /Dependency Score/.test(gov);
|
||||
const hasLearning = /Learning Score/.test(gov);
|
||||
const all5 = hasDelivery && hasQuality && hasParallelism && hasDependency && hasLearning;
|
||||
check('GV', '7a', '§7a All 5 Health Score dimensions defined (Delivery/Quality/Parallelism/Dependency/Learning)',
|
||||
all5,
|
||||
'All 5 dimensions found with formulas',
|
||||
'Ensure all 5 dimensions: Delivery, Quality, Parallelism, Dependency, Learning');
|
||||
|
||||
// Weighted formula
|
||||
const hasWeighted = /Health Score\s?=\s?Delivery\s*[×x]\s*0\.\d+/.test(gov);
|
||||
check('GV', '7b', '§7b Weighted final formula defined', hasWeighted,
|
||||
'Found weighted formula: Health Score = Delivery×0.30 + Quality×0.25 + Parallelism×0.15 + Dependency×0.15 + Learning×0.15',
|
||||
'Define the weighted sum formula for final Health Score');
|
||||
|
||||
// Rating
|
||||
const hasRating = /EXCELLENT[\s\S]*GOOD[\s\S]*ACCEPTABLE[\s\S]*AT RISK[\s\S]*CRITICAL/.test(gov) || /protocol-reference\/metrics\.md/.test(gov);
|
||||
check('GV', '7c', '§7c Health Score rating defined (EXCELLENT/GOOD/ACCEPTABLE/AT RISK/CRITICAL)',
|
||||
hasRating,
|
||||
'Found 5-level rating in GOVERNANCE.md or protocol-reference/metrics.md',
|
||||
'Add 5-level rating to protocol-reference/metrics.md');
|
||||
|
||||
// ---- §8 Auto-Stop ----
|
||||
const hasAutoStop = /§8 Auto-Stop/.test(gov);
|
||||
check('GV', '8', '§8 Auto-Stop Rule defined', hasAutoStop,
|
||||
'Found "§8 Auto-Stop Rule" with A1-A3 conditions and RECOVERY MODE',
|
||||
'Add §8 Auto-Stop Rule with A1(>3 failures), A2(Health<60), A3(Critical dep failed)');
|
||||
|
||||
// Recovery mode
|
||||
const hasRecoveryMode = /RECOVERY MODE/.test(gov);
|
||||
check('GV', '8a', '§8a RECOVERY MODE defined', hasRecoveryMode,
|
||||
'Found RECOVERY MODE with recovery steps and user approval requirement',
|
||||
'Add RECOVERY MODE procedure (stop, analyze, plan, user approval)');
|
||||
|
||||
// ---- §9 Quality Gate ----
|
||||
const hasGovGate = /§9 Governance Quality Gate/.test(gov);
|
||||
check('GV', '9', '§9 Governance Quality Gate defined', hasGovGate,
|
||||
'Found "§9 Governance Quality Gate" with 8-item checklist',
|
||||
'Add §9 Governance Quality Gate with 8 pre-output checks');
|
||||
|
||||
// ---- §10 State Initialization ----
|
||||
const hasInit = /§10 Governance State Initialization/.test(gov);
|
||||
check('GV', '10', '§10 Governance State Initialization defined', hasInit,
|
||||
'Found "§10 Governance State Initialization" with project start/handoff/override',
|
||||
'Add §10 Governance State Initialization section');
|
||||
|
||||
// ---- §11 Integration ----
|
||||
const hasIntegration = /§11 Integration Summary/.test(gov);
|
||||
check('GV', '11', '§11 Integration Summary defined', hasIntegration,
|
||||
'Found "§11 Integration Summary" with raw diagram and lifecycle',
|
||||
'Add §11 Integration Summary');
|
||||
|
||||
// ---- §11 Integration Summary (re-numbered — §12 was deleted) ----
|
||||
const hasInt11 = /§11 Integration Summary/.test(gov);
|
||||
check('GV', '11b', '§11 Integration Summary includes Portfolio ref', hasInt11,
|
||||
'Found §11 Integration Summary (with governance→portfolio data flow)',
|
||||
'Portfolio Integration reference is covered by §11 Integration Summary');
|
||||
|
||||
// ---- Runtime artifacts scan ----
|
||||
// Scan for actual progress.log files
|
||||
const progressLogs = findFiles(root, 'progress.log');
|
||||
const blockerFiles = findFiles(root, 'blocker.md');
|
||||
const execAudits = findFiles(root, 'execution-audit.md');
|
||||
|
||||
warn('GV', 'RA-progress', `Progress logs found: ${progressLogs.length}`,
|
||||
progressLogs.length > 0
|
||||
? `Found ${progressLogs.length} progress.log file(s): ${progressLogs.join(', ')}`
|
||||
: 'No progress.log files found (expected during active project execution)',
|
||||
progressLogs.length === 0 ? 'Active projects should have progress.log heartbeat entries' : undefined);
|
||||
|
||||
warn('GV', 'RA-blocker', `Blocker files found: ${blockerFiles.length}`,
|
||||
blockerFiles.length > 0
|
||||
? `Found ${blockerFiles.length} blocker.md file(s): ${blockerFiles.join(', ')}`
|
||||
: 'No blocker.md files found',
|
||||
blockerFiles.length === 0 ? '(no active blockers — good)' : undefined);
|
||||
|
||||
warn('GV', 'RA-audit', `Execution audits found: ${execAudits.length}`,
|
||||
execAudits.length > 0
|
||||
? `Found ${execAudits.length} execution-audit.md file(s): ${execAudits.join(', ')}`
|
||||
: 'No execution-audit.md found (expected after first project execution)',
|
||||
execAudits.length === 0 ? 'Run first project and generate execution-audit.md' : undefined);
|
||||
|
||||
// ---- Health Score calculation (from available data) ----
|
||||
// Without actual project data, report N/A
|
||||
checks.push({
|
||||
id: 'GV-HS',
|
||||
name: 'Project Health Score calculated',
|
||||
status: 'WARN',
|
||||
evidence: 'No active project data available for health score calculation. Formula is defined in GOVERNANCE.md §7.',
|
||||
suggestion: 'Health Score will be calculated during/after project execution',
|
||||
});
|
||||
|
||||
// ---- Summary ----
|
||||
const passCount = checks.filter(c => c.status === 'PASS').length;
|
||||
const warnCount = checks.filter(c => c.status === 'WARN').length;
|
||||
const failCount = checks.filter(c => c.status === 'FAIL').length;
|
||||
checks.push({
|
||||
id: 'GV-SUMMARY',
|
||||
name: `Summary — ${passCount} PASS / ${warnCount} WARN / ${failCount} FAIL`,
|
||||
status: failCount > 0 ? 'FAIL' : warnCount > 0 ? 'WARN' : 'PASS',
|
||||
evidence: `${passCount} passed, ${warnCount} warnings, ${failCount} failures`,
|
||||
});
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
/** Recursively find files by name (limited depth) */
|
||||
function findFiles(dir, filename, depth = 3) {
|
||||
const results = [];
|
||||
if (depth <= 0) return results;
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name === filename) {
|
||||
results.push(path.relative(WORKSPACE, path.join(dir, entry.name)));
|
||||
}
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
||||
results.push(...findFiles(path.join(dir, entry.name), filename, depth - 1));
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- Standalone runner ----
|
||||
if (SELF.endsWith('/audit-governance.mjs') || SELF.endsWith('\\audit-governance.mjs')) {
|
||||
const results = runGovernanceAudit(WORKSPACE);
|
||||
console.log(`\n=== GOVERNANCE.md Audit ===\n`);
|
||||
for (const r of results) {
|
||||
const icon = r.status === 'PASS' ? '✅' : r.status === 'WARN' ? '⚠️' : '❌';
|
||||
console.log(` ${icon} [${r.id}] ${r.name}`);
|
||||
console.log(` ${r.evidence}`);
|
||||
if (r.suggestion) console.log(` → ${r.suggestion}`);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* audit-portfolio.mjs — PORTFOLIO.md Portfolio Management Audit
|
||||
*
|
||||
* Checks:
|
||||
* 1. portfolio.md exists (template or actual)
|
||||
* 2. Project states are legal (7-state portfolio states)
|
||||
* 3. Priority / Health / ROI / Agent Allocation fields are complete
|
||||
* 4. Duplicate work detection mechanism exists
|
||||
* 5. Portfolio Health Score formula defined
|
||||
* 6. Kill / Pause / Promotion rules defined
|
||||
* 7. Executive Dashboard schema defined
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/audit-portfolio.mjs ← standalone
|
||||
* import { runPortfolioAudit } from './audit-portfolio.mjs' ← via orchestrator
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const WORKSPACE = path.resolve(__dirname, '..');
|
||||
const SELF = process.argv[1].replace(/\\/g, '/');
|
||||
|
||||
/**
|
||||
* @typedef {Object} CheckResult
|
||||
* @property {string} id
|
||||
* @property {string} name
|
||||
* @property {'PASS'|'WARN'|'FAIL'} status
|
||||
* @property {string} evidence
|
||||
* @property {string} [suggestion]
|
||||
*/
|
||||
|
||||
/** @returns {CheckResult[]} */
|
||||
export function runPortfolioAudit(workspaceDir) {
|
||||
const root = workspaceDir || WORKSPACE;
|
||||
const checks = [];
|
||||
|
||||
// ---- Helpers ----
|
||||
function readFile(rel) {
|
||||
const p = path.join(root, rel);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
return fs.readFileSync(p, 'utf8');
|
||||
}
|
||||
|
||||
function check(prefix, id, name, ok, passEv, failEv, suggestion) {
|
||||
checks.push({
|
||||
id: `${prefix}-${id}`,
|
||||
name,
|
||||
status: ok ? 'PASS' : 'FAIL',
|
||||
evidence: ok ? passEv : failEv,
|
||||
...(ok ? {} : { suggestion: suggestion || `Implement ${id} in PORTFOLIO.md` }),
|
||||
});
|
||||
}
|
||||
|
||||
function warn(prefix, id, name, evidence, suggestion) {
|
||||
checks.push({ id: `${prefix}-${id}`, name, status: 'WARN', evidence, suggestion });
|
||||
}
|
||||
|
||||
// ---- Read protocol file ----
|
||||
const port = readFile('PORTFOLIO.md');
|
||||
if (!port) {
|
||||
checks.push({
|
||||
id: 'PF-001',
|
||||
name: 'PORTFOLIO.md exists',
|
||||
status: 'FAIL',
|
||||
evidence: 'PORTFOLIO.md not found in workspace root',
|
||||
suggestion: 'Create PORTFOLIO.md with the Portfolio Management Protocol',
|
||||
});
|
||||
return checks;
|
||||
}
|
||||
checks.push({
|
||||
id: 'PF-001',
|
||||
name: 'PORTFOLIO.md exists',
|
||||
status: 'PASS',
|
||||
evidence: `PORTFOLIO.md found (${port.length} bytes)`,
|
||||
});
|
||||
|
||||
// ---- §1 Portfolio Registry ----
|
||||
const hasReg = /§1 Portfolio Registry/.test(port);
|
||||
check('PF', '1', '§1 Portfolio Registry defined', hasReg,
|
||||
'Found "§1 Portfolio Registry" with schema and field definitions',
|
||||
'Add §1 Portfolio Registry section');
|
||||
|
||||
// Registry schema has all required fields
|
||||
const hasFields = /Project-ID.*Name.*Status.*Priority.*Health.*Agents.*Progress.*Last Updated.*ROI/.test(port);
|
||||
check('PF', '1a', '§1a Registry schema: all 9 fields (ID/Name/Status/Priority/Health/Agents/Progress/Updated/ROI)',
|
||||
hasFields,
|
||||
'Registry schema found with all 9 fields',
|
||||
'Ensure registry schema includes: Project-ID, Name, Status, Priority, Health, Agents, Progress, Last Updated, ROI');
|
||||
|
||||
// portfolio.md template exists
|
||||
const portTmpl = readFile('scripts/templates/portfolio.md.template');
|
||||
check('PF', '1b', 'portfolio.md.template exists', portTmpl !== null,
|
||||
'Found scripts/templates/portfolio.md.template',
|
||||
'Create scripts/templates/portfolio.md.template');
|
||||
|
||||
// Actual portfolio.md in workspace?
|
||||
const actualPortfolio = readFile('portfolio.md');
|
||||
warn('PF', '1c', 'Actual portfolio.md in workspace root',
|
||||
actualPortfolio
|
||||
? `Found portfolio.md (${actualPortfolio.length} bytes)`
|
||||
: 'No portfolio.md found — expected when projects are active',
|
||||
actualPortfolio ? undefined : 'Create portfolio.md when first project becomes active');
|
||||
|
||||
// ---- §2 Project States ----
|
||||
const hasStates = /§2 Project States/.test(port);
|
||||
check('PF', '2', '§2 Project States defined', hasStates,
|
||||
'Found "§2 Project States" with 7 portfolio states and transition rules',
|
||||
'Add §2 Project States with PLANNING/ACTIVE/BLOCKED/RECOVERY/PAUSED/COMPLETED/ARCHIVED');
|
||||
|
||||
// 7 states
|
||||
const states = ['PLANNING', 'ACTIVE', 'BLOCKED', 'RECOVERY', 'PAUSED', 'COMPLETED', 'ARCHIVED'];
|
||||
const foundStates = states.filter(s => port.includes(s));
|
||||
check('PF', '2a', `§2a ${states.length} portfolio states defined (found ${foundStates.length})`,
|
||||
foundStates.length >= states.length,
|
||||
`All ${states.length} states present: ${foundStates.join(', ')}`,
|
||||
`Missing: ${states.filter(s => !port.includes(s)).join(', ')}`,
|
||||
'Add missing portfolio states');
|
||||
|
||||
// State diagram
|
||||
const hasDiagram = /stateDiagram-v2/.test(port);
|
||||
check('PF', '2b', '§2b Portfolio state transition diagram (mermaid)', hasDiagram,
|
||||
'Found mermaid stateDiagram-v2 for portfolio states',
|
||||
'Add mermaid stateDiagram-v2 for portfolio state transitions');
|
||||
|
||||
// Prohibited transitions
|
||||
const hasProhibited = /Prohibited Transitions[\s\S]*❌/.test(port);
|
||||
check('PF', '2c', '§2c Prohibited portfolio transitions defined', hasProhibited,
|
||||
'Found Prohibited Transitions with ❌ markers',
|
||||
'Add Prohibited Transitions section for portfolio states');
|
||||
|
||||
// ---- §3 Priority System ----
|
||||
const hasPriority = /§3 Priority System/.test(port);
|
||||
check('PF', '3', '§3 Priority System defined (P0/P1/P2/P3)', hasPriority,
|
||||
'Found "§3 Priority System" with P0(P1(P2(P3 levels)',
|
||||
'Add §3 Priority System with P0(P1(P2(P3 levels');
|
||||
|
||||
const hasP0 = port.includes('P0');
|
||||
const hasP1 = port.includes('P1');
|
||||
const hasP2 = port.includes('P2');
|
||||
const hasP3 = port.includes('P3');
|
||||
check('PF', '3a', `§3a All priority levels: P0/P1/P2/P3`, hasP0 && hasP1 && hasP2 && hasP3,
|
||||
`P0=${hasP0}, P1=${hasP1}, P2=${hasP2}, P3=${hasP3}`,
|
||||
'Define all 4 priority levels: P0(Mission Critical), P1(High Value), P2(Normal), P3(Low Priority)');
|
||||
|
||||
// Collision resolution
|
||||
const hasCollision = /Priority Collision/.test(port);
|
||||
check('PF', '3b', '§3b Priority collision resolution defined', hasCollision,
|
||||
'Found Priority Collision Resolution: ROI sort → dependency sort → user decision',
|
||||
'Add Priority Collision Resolution for multiple P0 conflicts');
|
||||
|
||||
// ---- §4 Resource Allocation ----
|
||||
const hasResources = /§4 Resource Allocation/.test(port);
|
||||
check('PF', '4', '§4 Resource Allocation defined', hasResources,
|
||||
'Found "§4 Resource Allocation" with Agent Inventory / Utilization Score / Rebalancing',
|
||||
'Add §4 Resource Allocation section');
|
||||
|
||||
// Agent inventory = 5 metrics
|
||||
const hasTotal = port.includes('Total Agents');
|
||||
const hasBusy = port.includes('Busy Agents');
|
||||
const hasIdle = port.includes('Idle Agents');
|
||||
const hasBlocked = port.includes('Blocked Agents');
|
||||
const hasRecovery = port.includes('Recovery Agents');
|
||||
const all5inv = hasTotal && hasBusy && hasIdle && hasBlocked && hasRecovery;
|
||||
check('PF', '4a', `§4a Agent inventory: all 5 metrics (Total/Busy/Idle/Blocked/Recovery)`,
|
||||
all5inv,
|
||||
`Total=${hasTotal}, Busy=${hasBusy}, Idle=${hasIdle}, Blocked=${hasBlocked}, Recovery=${hasRecovery}`,
|
||||
'Define Agent Inventory with Total/Busy/Idle/Blocked/Recovery metrics');
|
||||
|
||||
// Utilization formula
|
||||
const hasUtilFormula = /Utilization\s*=\s*Busy_Agents\s*\/\s*Total_Agents/.test(port);
|
||||
check('PF', '4b', '§4b Agent Utilization formula defined', hasUtilFormula,
|
||||
'Found Utilization = Busy_Agents / Total_Agents with 4-level rating',
|
||||
'Define Agent Utilization Score formula and rating');
|
||||
|
||||
// ---- §5 Project Health Aggregation ----
|
||||
const hasAgg = /§5 Project Health Aggregation/.test(port);
|
||||
check('PF', '5', '§5 Project Health Aggregation defined', hasAgg,
|
||||
'Found "§5 Project Health Aggregation" with 5 dimensions and weights',
|
||||
'Add §5 Project Health Aggregation with Delivery/Quality/Learning/Execution/Risk dimensions');
|
||||
|
||||
// ---- §6 Portfolio Health Score ----
|
||||
const hasPortHealth = /§6 Portfolio Health Score/.test(port);
|
||||
check('PF', '6', '§6 Portfolio Health Score defined', hasPortHealth,
|
||||
'Found "§6 Portfolio Health Score" with weighted average formula',
|
||||
'Add §6 Portfolio Health Score with weighted average calculation');
|
||||
|
||||
// Weighted formula
|
||||
const hasWeighted = /加权[\s\S]*平均/.test(port) || /weighted/i.test(port) || /Σ\(Project_Health[\s\S]*Agent_Count\)/.test(port);
|
||||
check('PF', '6a', '§6a Weighted portfolio formula defined', hasWeighted,
|
||||
'Found weighted portfolio formula (Project_Health × Agent_Count / Σ Agent_Count)',
|
||||
'Define Portfolio_Health = Σ(Project_Health × Agent_Count) / Σ(Agent_Count)');
|
||||
|
||||
// ---- §7 Learning Reuse ----
|
||||
const hasReuse = /§7 Learning Reuse/.test(port);
|
||||
check('PF', '7', '§7 Learning Reuse Detector defined', hasReuse,
|
||||
'Found "§7 Learning Reuse Detector" with detection criteria and action plan',
|
||||
'Add §7 Learning Reuse Detector with duplicate work detection');
|
||||
|
||||
// Duplicate work criteria
|
||||
const hasDupCriteria = /Duplicate Work/.test(port);
|
||||
check('PF', '7a', '§7a Duplicate Work criteria defined', hasDupCriteria,
|
||||
'Found Duplicate Work criteria (30% overlap, same Pattern Class, same deps, same domain)',
|
||||
'Add Duplicate Work criteria with actionable thresholds');
|
||||
|
||||
// ---- §8 Portfolio Audit ----
|
||||
const hasAudit = /§8 Portfolio Audit/.test(port);
|
||||
check('PF', '8', '§8 Portfolio Audit defined', hasAudit,
|
||||
'Found "§8 Portfolio Audit" with daily generation and audit schema',
|
||||
'Add §8 Portfolio Audit with daily generation and schema');
|
||||
|
||||
// portfolio-audit.md template exists
|
||||
const auditTmpl = readFile('scripts/templates/portfolio-audit.md.template');
|
||||
check('PF', '8a', 'portfolio-audit.md.template exists', auditTmpl !== null,
|
||||
'Found scripts/templates/portfolio-audit.md.template',
|
||||
'Create scripts/templates/portfolio-audit.md.template');
|
||||
|
||||
// ---- §9 Kill Rule ----
|
||||
const hasKill = /§9 Project Kill/.test(port);
|
||||
check('PF', '9', '§9 Project Kill Rule defined', hasKill,
|
||||
'Found "§9 Project Kill Rule" with K1-K4 conditions and graceful shutdown',
|
||||
'Add §9 Project Kill Rule with K1(Health<50), K2(>3 recovery fails), K3(ROI<threshold), K4(Scope+P3)');
|
||||
|
||||
// ---- §10 Pause Rule ----
|
||||
const hasPause = /§10 Project Pause/.test(port);
|
||||
check('PF', '10', '§10 Project Pause Rule defined', hasPause,
|
||||
'Found "§10 Project Pause Rule" with P1-P3 conditions and resume decision',
|
||||
'Add §10 Project Pause Rule with P1(Priority), P2(Resources), P3(Dependency)');
|
||||
|
||||
// ---- §11 Promotion Rule ----
|
||||
const hasPromotion = /§11 Project Promotion/.test(port);
|
||||
check('PF', '11', '§11 Project Promotion Rule defined', hasPromotion,
|
||||
'Found "§11 Project Promotion Rule" with F1(Health>90), F2(Progress>80%), F3(Risk<20)',
|
||||
'Add §11 Project Promotion Rule with Fast Track criteria');
|
||||
|
||||
// ---- §12 Executive Dashboard ----
|
||||
const hasDash = /§12 Executive Dashboard/.test(port);
|
||||
check('PF', '12', '§12 Executive Dashboard defined', hasDash,
|
||||
'Found "§12 Executive Dashboard" with Portfolio Health / Top Projects / Risks / Opportunities / Utilization / Learning Reuse / Quick Actions',
|
||||
'Add §12 Executive Dashboard schema');
|
||||
|
||||
// dashboard.md template exists
|
||||
const dashTmpl = readFile('scripts/templates/dashboard.md.template');
|
||||
check('PF', '12a', 'dashboard.md.template exists', dashTmpl !== null,
|
||||
'Found scripts/templates/dashboard.md.template',
|
||||
'Create scripts/templates/dashboard.md.template');
|
||||
|
||||
// ---- §13 Quality Gate ----
|
||||
const hasQualityGate = /§13 Portfolio Quality Gate/.test(port);
|
||||
check('PF', '13', '§13 Portfolio Quality Gate defined', hasQualityGate,
|
||||
'Found "§13 Portfolio Quality Gate" with 8-item checklist',
|
||||
'Add §13 Portfolio Quality Gate with 8 pre-output checks');
|
||||
|
||||
// ---- §14 Integration ----
|
||||
const hasIntegration = /§14 Integration Summary/.test(port);
|
||||
check('PF', '14', '§14 Integration Summary defined', hasIntegration,
|
||||
'Found "§14 Integration Summary" with data flow and lifecycle',
|
||||
'Add §14 Integration Summary with data flow and lifecycle diagram');
|
||||
|
||||
// ---- Cross-refs ----
|
||||
const refsGovernance = (port.match(/GOVERNANCE\.md/g) || []).length;
|
||||
warn('PF', 'XR-gov', `Cross-references to GOVERNANCE.md: ${refsGovernance} (target: ≥5)`,
|
||||
`Found ${refsGovernance} references to GOVERNANCE.md`,
|
||||
'PORTFOLIO.md should reference GOVERNANCE.md for project-level health/blockers');
|
||||
|
||||
const refsAgents = (port.match(/AGENTS\.md/g) || []).length;
|
||||
warn('PF', 'XR-ag', `Cross-references to AGENTS.md: ${refsAgents} (target: ≥2)`,
|
||||
`Found ${refsAgents} references to AGENTS.md`,
|
||||
'PORTFOLIO.md should reference AGENTS.md for execution protocol');
|
||||
|
||||
// ---- Duplicate work scan ----
|
||||
const projectDirs = findProjectDirs(root);
|
||||
warn('PF', 'DW-scan', `Potential duplicate work scan: ${projectDirs.length} project directories found`,
|
||||
projectDirs.length > 0
|
||||
? `Found ${projectDirs.length} project directories. Manual review recommended for duplicate patterns: ${projectDirs.join(', ')}`
|
||||
: 'No project directories found for duplicate work analysis',
|
||||
projectDirs.length >= 2 ? 'Check for duplicate code across project directories' : undefined);
|
||||
|
||||
// ---- Portfolio Health Score (from available data) ----
|
||||
checks.push({
|
||||
id: 'PF-HS',
|
||||
name: 'Portfolio Health Score calculated',
|
||||
status: 'WARN',
|
||||
evidence: 'No active project data available for portfolio health calculation. Formula is defined in PORTFOLIO.md §6.',
|
||||
suggestion: 'Portfolio Health will be calculated after at least one project has a Health Score',
|
||||
});
|
||||
|
||||
// ---- Summary ----
|
||||
const passCount = checks.filter(c => c.status === 'PASS').length;
|
||||
const warnCount = checks.filter(c => c.status === 'WARN').length;
|
||||
const failCount = checks.filter(c => c.status === 'FAIL').length;
|
||||
checks.push({
|
||||
id: 'PF-SUMMARY',
|
||||
name: `Summary — ${passCount} PASS / ${warnCount} WARN / ${failCount} FAIL`,
|
||||
status: failCount > 0 ? 'FAIL' : warnCount > 0 ? 'WARN' : 'PASS',
|
||||
evidence: `${passCount} passed, ${warnCount} warnings, ${failCount} failures`,
|
||||
});
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
/** Find all directories that look like projects (have progress.log or AGENTS.md ref) */
|
||||
function findProjectDirs(root) {
|
||||
const candidates = [];
|
||||
try {
|
||||
const entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory() && !e.name.startsWith('.') && !['node_modules', 'scripts', 'memory', 'repos'].includes(e.name)) {
|
||||
// Check if this dir has project-like files
|
||||
const sub = path.join(root, e.name);
|
||||
const hasProgress = fs.existsSync(path.join(sub, 'progress.log'));
|
||||
const hasReadme = fs.existsSync(path.join(sub, 'README.md'));
|
||||
const hasPkg = fs.existsSync(path.join(sub, 'package.json'));
|
||||
if (hasProgress || hasReadme || hasPkg) {
|
||||
candidates.push(e.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
return candidates;
|
||||
}
|
||||
|
||||
// ---- Standalone runner ----
|
||||
if (SELF.endsWith('/audit-portfolio.mjs') || SELF.endsWith('\\audit-portfolio.mjs')) {
|
||||
const results = runPortfolioAudit(WORKSPACE);
|
||||
console.log(`\n=== PORTFOLIO.md Audit ===\n`);
|
||||
for (const r of results) {
|
||||
const icon = r.status === 'PASS' ? '✅' : r.status === 'WARN' ? '⚠️' : '❌';
|
||||
console.log(` ${icon} [${r.id}] ${r.name}`);
|
||||
console.log(` ${r.evidence}`);
|
||||
if (r.suggestion) console.log(` → ${r.suggestion}`);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# auto-checkpoint.sh — 会话 checkpoint(compact 前自动调用)
|
||||
# 把当前会话的关键信息刷出对话历史,减少上下文膨胀带来的 token 开销
|
||||
|
||||
WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
MEMORY_SERVER="http://111.229.145.18"
|
||||
DAILY="$WORKSPACE/memory/daily/$(date +%Y-%m-%d).md"
|
||||
NOW=$(date +%H:%M)
|
||||
|
||||
# 生成一条会话摘要(从 daily 最后一段提取)
|
||||
SUMMARY=$(tail -20 "$DAILY" 2>/dev/null | grep '^\[' | tail -1 | sed 's/^\[//;s/\]//;s/^[^:]*://' | head -c 300)
|
||||
|
||||
# 如果 daily 没有合适的摘要,手动给一个
|
||||
if [ -z "$SUMMARY" ]; then
|
||||
SUMMARY="[$NOW] 会话 checkpoint — 上下文已 compact"
|
||||
fi
|
||||
|
||||
# 写进 daily
|
||||
echo "" >> "$DAILY"
|
||||
echo "[checkpoint $NOW] $SUMMARY" >> "$DAILY"
|
||||
|
||||
# 推送远程记忆
|
||||
curl -s --max-time 5 -X POST "$MEMORY_SERVER/api/v2/add" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: xl_134…220f" \
|
||||
-d "{\"content\":\"$SUMMARY\",\"type\":\"checkpoint\",\"project\":\"xiaolong\",\"source\":\"auto-checkpoint\"}" > /dev/null 2>&1
|
||||
|
||||
echo "✅ Checkpoint done: $SUMMARY"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,472 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* PR-C Deprecation Check Script
|
||||
* Scans OpenClaw config and plugin state for deprecated modules.
|
||||
*
|
||||
* Run: node scripts/check-deprecations.mjs
|
||||
*
|
||||
* Checks:
|
||||
* 1. Active Memory blocking mode
|
||||
* 2. Dreaming REM phase enabled
|
||||
* 3. Dream Diary generation
|
||||
* 4. QMD search mode / QMD engine references
|
||||
* 5. Honcho memory plugin enabled
|
||||
* 6. LanceDB memory plugin enabled
|
||||
* 7. memory-wiki plugin loaded
|
||||
* 8. Commitments auto-infer
|
||||
* 9. Grounded Backfill / REM Backfill CLI usage
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { warnOnce, warnMany } from "./lib/deprecation-warning.mjs";
|
||||
|
||||
const AGENTS_HOME = join(homedir(), ".openclaw");
|
||||
const CONFIG_PATH = join(AGENTS_HOME, "openclaw.json");
|
||||
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || "/opt/homebrew/lib/node_modules/openclaw";
|
||||
const EXTENSIONS_DIR = join(OPENCLAW_HOME, "dist", "extensions");
|
||||
|
||||
// Load user config
|
||||
function loadConfig() {
|
||||
if (!existsSync(CONFIG_PATH)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Load plugin entry for a given extension id
|
||||
function loadPlugin(id) {
|
||||
const pluginJsonPath = join(EXTENSIONS_DIR, id, "openclaw.plugin.json");
|
||||
if (!existsSync(pluginJsonPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(pluginJsonPath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a plugin is enabled in user config
|
||||
function isPluginEnabled(config, pluginId) {
|
||||
const entries = config?.plugins?.entries;
|
||||
if (!entries) return false;
|
||||
const entry = entries[pluginId];
|
||||
if (!entry) return false;
|
||||
return entry.enabled !== false;
|
||||
}
|
||||
|
||||
// ─── Check Functions ────────────────────────────────
|
||||
|
||||
function checkActiveMemoryBlocking(config) {
|
||||
const warnings = [];
|
||||
const entry = config?.plugins?.entries?.["active-memory"];
|
||||
if (!entry) return warnings;
|
||||
|
||||
const mode = entry.config?.mode;
|
||||
if (mode === "blocking") {
|
||||
warnings.push({
|
||||
key: "active-memory.blocking",
|
||||
message: "active-memory blocking mode is deprecated. Set mode to 'precompute' instead."
|
||||
});
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function checkDreamingRemPhase(config) {
|
||||
const warnings = [];
|
||||
const entry = config?.plugins?.entries?.["memory-core"];
|
||||
if (!entry) return warnings;
|
||||
|
||||
const dreaming = entry.config?.dreaming;
|
||||
if (!dreaming) return warnings;
|
||||
|
||||
// Check REM phase enabled
|
||||
const remEnabled = dreaming?.phases?.rem?.enabled;
|
||||
if (remEnabled === true) {
|
||||
warnings.push({
|
||||
key: "dreaming.rem-phase",
|
||||
message: "Dreaming REM phase is deprecated. It will be replaced by Collect/Promote."
|
||||
});
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function checkDreamDiary(config) {
|
||||
const warnings = [];
|
||||
const entry = config?.plugins?.entries?.["memory-core"];
|
||||
if (!entry) return warnings;
|
||||
|
||||
const dreaming = entry.config?.dreaming;
|
||||
if (!dreaming) return warnings;
|
||||
|
||||
// Dream Diary is implied by REM phase or certain storage modes
|
||||
const storage = dreaming?.storage;
|
||||
const hasDiaryMode = storage?.mode === "separate" || storage?.mode === "both";
|
||||
const separateReports = storage?.separateReports === true;
|
||||
|
||||
if (hasDiaryMode || separateReports) {
|
||||
warnings.push({
|
||||
key: "dreaming.dream-diary",
|
||||
message: "Dream Diary generation is deprecated and will be removed."
|
||||
});
|
||||
}
|
||||
|
||||
// Also check if dreaming has a configured model (used for diary)
|
||||
if (dreaming?.model) {
|
||||
warnings.push({
|
||||
key: "dreaming.diary-model",
|
||||
message: "Dreaming diary model override is deprecated."
|
||||
});
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function checkQmdReference(config) {
|
||||
const warnings = [];
|
||||
|
||||
// Check memory search config for QMD
|
||||
const memorySearch = config?.agents?.defaults?.memorySearch;
|
||||
if (memorySearch?.provider === "qmd" || memorySearch?.backend === "qmd") {
|
||||
warnings.push({
|
||||
key: "qmd.memory-search",
|
||||
message: "QMD memory engine is legacy. Migrate to Builtin MemoryCore."
|
||||
});
|
||||
}
|
||||
|
||||
// Check active-memory QMD reference
|
||||
const activeCfg = config?.plugins?.entries?.["active-memory"]?.config;
|
||||
if (activeCfg?.qmd?.searchMode) {
|
||||
warnings.push({
|
||||
key: "qmd.active-memory",
|
||||
message: "QMD search mode in active-memory is legacy. Remove qmd config."
|
||||
});
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function checkHonchoLanceDB(config) {
|
||||
const warnings = [];
|
||||
|
||||
// Check if honcho or lancedb plugins are loaded
|
||||
const pluginEntries = config?.plugins?.entries || {};
|
||||
|
||||
if (pluginEntries["memory-honcho"]?.enabled !== false && isPluginInExtensions("honcho")) {
|
||||
warnings.push({
|
||||
key: "honcho.plugin",
|
||||
message: "Honcho memory plugin is legacy. Existing data readable during migration."
|
||||
});
|
||||
}
|
||||
|
||||
if (pluginEntries["memory-lancedb"]?.enabled !== false && isPluginInExtensions("lancedb")) {
|
||||
warnings.push({
|
||||
key: "lancedb.plugin",
|
||||
message: "LanceDB memory plugin is legacy. Existing data readable during migration."
|
||||
});
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function checkMemoryWiki(config) {
|
||||
const warnings = [];
|
||||
const entry = config?.plugins?.entries?.["memory-wiki"];
|
||||
|
||||
if (entry?.enabled !== false) {
|
||||
// Check if memory-wiki extension exists and isn't disabled
|
||||
const wikiPlugin = loadPlugin("memory-wiki");
|
||||
if (wikiPlugin) {
|
||||
warnings.push({
|
||||
key: "memory-wiki.plugin",
|
||||
message: "memory-wiki plugin is legacy. wiki_search/wiki_get/wiki_apply/wiki_lint/wiki_status remain available during migration."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function checkCommitmentsAutoInfer(config) {
|
||||
const warnings = [];
|
||||
|
||||
// Commitments auto-infer is part of the session-memory bundled module
|
||||
const commitments = config?.commitments;
|
||||
if (commitments?.autoInfer === true) {
|
||||
warnings.push({
|
||||
key: "commitments.auto-infer",
|
||||
message: "Commitments auto-infer is deprecated. Use explicit cron tasks instead."
|
||||
});
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function checkGroundedBackfill(config) {
|
||||
const warnings = [];
|
||||
|
||||
// Grounded Backfill is a CLI feature — we check if related config exists
|
||||
const dreaming = config?.plugins?.entries?.["memory-core"]?.config?.dreaming;
|
||||
if (!dreaming) return warnings;
|
||||
|
||||
// If dreaming has unusual storage paths that suggest backfill usage
|
||||
const storage = dreaming?.storage;
|
||||
if (storage?.mode === "separate") {
|
||||
warnings.push({
|
||||
key: "backfill.storage-mode",
|
||||
message: "Grounded Backfill / REM Backfill is deprecated and will not evolve further."
|
||||
});
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────
|
||||
|
||||
function isPluginInExtensions(name) {
|
||||
try {
|
||||
const dirs = readdirSync(EXTENSIONS_DIR, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory());
|
||||
|
||||
for (const d of dirs) {
|
||||
if (d.name.includes(name)) return true;
|
||||
// Check plugin.json
|
||||
const pluginPath = join(EXTENSIONS_DIR, d.name, "openclaw.plugin.json");
|
||||
if (existsSync(pluginPath)) {
|
||||
const plugin = JSON.parse(readFileSync(pluginPath, "utf8"));
|
||||
if (plugin.id?.includes(name)) return true;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Legacy Registry (PR-1 Freeze) ───────────────────
|
||||
// Maps deprecation keys to replacement info and migration window.
|
||||
// Used to enhance check-deprecations output.
|
||||
|
||||
const LEGACY_REGISTRY = {
|
||||
"active-memory.blocking": {
|
||||
module: "active-memory blocking mode",
|
||||
status: "Active. Deprecated.",
|
||||
replacement: 'Set mode to "precompute" in active-memory config',
|
||||
migrationWindow: "90 days from 2026-06-04",
|
||||
removalTarget: "2026-09-04",
|
||||
},
|
||||
"dreaming.rem-phase": {
|
||||
module: "Dreaming REM phase",
|
||||
status: "Active. Deprecated.",
|
||||
replacement: "Collect/Promote two-phase dreaming (PR-5)",
|
||||
migrationWindow: "90 days from 2026-06-04",
|
||||
removalTarget: "2026-09-04",
|
||||
},
|
||||
"dreaming.dream-diary": {
|
||||
module: "Dream Diary",
|
||||
status: "Active. Deprecated.",
|
||||
replacement: "Inline dreaming output (no separate diary storage)",
|
||||
migrationWindow: "90 days from 2026-06-04",
|
||||
removalTarget: "2026-09-04",
|
||||
},
|
||||
"dreaming.diary-model": {
|
||||
module: "Dream Diary model override",
|
||||
status: "Active. Deprecated.",
|
||||
replacement: "Use default dreaming model",
|
||||
migrationWindow: "90 days from 2026-06-04",
|
||||
removalTarget: "2026-09-04",
|
||||
},
|
||||
"qmd.memory-search": {
|
||||
module: "QMD memory search engine",
|
||||
status: "Active. Deprecated.",
|
||||
replacement: "Builtin MemoryCore FTS5 + BM25",
|
||||
migrationWindow: "90 days from 2026-06-04",
|
||||
removalTarget: "2026-09-04",
|
||||
},
|
||||
"qmd.active-memory": {
|
||||
module: "QMD active-memory search mode",
|
||||
status: "Active. Deprecated.",
|
||||
replacement: "Remove qmd config block from active-memory",
|
||||
migrationWindow: "90 days from 2026-06-04",
|
||||
removalTarget: "2026-09-04",
|
||||
},
|
||||
"honcho.plugin": {
|
||||
module: "Honcho memory plugin",
|
||||
status: "Legacy. Data readable during migration.",
|
||||
replacement: "memory-core (FTS5 + BM25)",
|
||||
migrationWindow: "60 days from 2026-06-04",
|
||||
removalTarget: "2026-08-04",
|
||||
},
|
||||
"lancedb.plugin": {
|
||||
module: "LanceDB memory plugin",
|
||||
status: "Legacy. Data readable during migration.",
|
||||
replacement: "memory-core (FTS5 + BM25)",
|
||||
migrationWindow: "60 days from 2026-06-04",
|
||||
removalTarget: "2026-08-04",
|
||||
},
|
||||
"memory-wiki.plugin": {
|
||||
module: "memory-wiki plugin",
|
||||
status: "Active. Deprecated.",
|
||||
replacement: "memory-core wiki compilation",
|
||||
migrationWindow: "90 days from 2026-06-04",
|
||||
removalTarget: "2026-09-04",
|
||||
},
|
||||
"commitments.auto-infer": {
|
||||
module: "Commitments auto-infer",
|
||||
status: "Active. Deprecated.",
|
||||
replacement: "Explicit cron tasks (use cron tool)",
|
||||
migrationWindow: "90 days from 2026-06-04",
|
||||
removalTarget: "2026-09-04",
|
||||
},
|
||||
"backfill.storage-mode": {
|
||||
module: "Grounded Backfill / REM Backfill CLI",
|
||||
status: "Active. Deprecated.",
|
||||
replacement: "No replacement. Stop using immediately.",
|
||||
migrationWindow: "Immediate — effective 2026-06-04",
|
||||
removalTarget: "2026-06-04",
|
||||
},
|
||||
};
|
||||
|
||||
function getLegacyInfo(key) {
|
||||
return LEGACY_REGISTRY[key] || null;
|
||||
}
|
||||
|
||||
function formatLegacyTable(warnings) {
|
||||
// Group warnings by their legacy info
|
||||
const seen = new Set();
|
||||
const rows = [];
|
||||
for (const w of warnings) {
|
||||
if (seen.has(w.key)) continue;
|
||||
seen.add(w.key);
|
||||
const info = LEGACY_REGISTRY[w.key] || {
|
||||
module: w.key,
|
||||
status: "Unknown",
|
||||
replacement: "N/A",
|
||||
migrationWindow: "N/A",
|
||||
removalTarget: "N/A",
|
||||
};
|
||||
rows.push(info);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────
|
||||
|
||||
function runAllChecks() {
|
||||
const config = loadConfig();
|
||||
const allWarnings = [];
|
||||
const checkResults = [];
|
||||
|
||||
if (!config) {
|
||||
console.warn("[DEPRECATED][OpenClaw v2] config: Could not load openclaw.json");
|
||||
return { warnings: [], results: [{ name: "config", error: "Could not load config" }] };
|
||||
}
|
||||
|
||||
const checks = [
|
||||
{ name: "active-memory blocking mode", fn: () => checkActiveMemoryBlocking(config) },
|
||||
{ name: "dreaming REM phase", fn: () => checkDreamingRemPhase(config) },
|
||||
{ name: "dream diary", fn: () => checkDreamDiary(config) },
|
||||
{ name: "QMD reference", fn: () => checkQmdReference(config) },
|
||||
{ name: "Honcho / LanceDB", fn: () => checkHonchoLanceDB(config) },
|
||||
{ name: "memory-wiki", fn: () => checkMemoryWiki(config) },
|
||||
{ name: "commitments auto-infer", fn: () => checkCommitmentsAutoInfer(config) },
|
||||
{ name: "grounded backfill", fn: () => checkGroundedBackfill(config) },
|
||||
];
|
||||
|
||||
console.log("");
|
||||
console.log("═══════════════════════════════════════");
|
||||
console.log(" PR-C: Deprecation Warning Check");
|
||||
console.log(` Architecture Freeze: 2026-06-04`);
|
||||
console.log(` ${new Date().toISOString()}`);
|
||||
console.log("═══════════════════════════════════════");
|
||||
console.log("");
|
||||
|
||||
for (const check of checks) {
|
||||
try {
|
||||
const warnings = check.fn();
|
||||
checkResults.push({ name: check.name, warnings, passed: warnings.length === 0 });
|
||||
allWarnings.push(...warnings);
|
||||
} catch (e) {
|
||||
checkResults.push({ name: check.name, warnings: [], passed: false, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Emit warnings (deduplicated)
|
||||
if (allWarnings.length > 0) {
|
||||
console.log(" ⚠ Deprecation warnings found:\n");
|
||||
for (const w of allWarnings) {
|
||||
warnOnce(w.key, w.message);
|
||||
}
|
||||
} else {
|
||||
console.log(" ✓ No deprecation warnings found.");
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log("");
|
||||
const checksWithWarnings = checkResults.filter(c => c.warnings.length > 0);
|
||||
const checksFailed = checkResults.filter(c => c.error);
|
||||
|
||||
console.log(` Checks run: ${checks.length}`);
|
||||
console.log(` With warnings: ${checksWithWarnings.length}`);
|
||||
console.log(` Total warnings: ${allWarnings.length}`);
|
||||
|
||||
if (checksFailed.length > 0) {
|
||||
console.log(` Check errors: ${checksFailed.length}`);
|
||||
for (const c of checksFailed) {
|
||||
console.log(` ⚠ ${c.name}: ${c.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PR-1 Enhanced Output: Legacy Table ─────────────
|
||||
if (allWarnings.length > 0) {
|
||||
const legacyRows = formatLegacyTable(allWarnings);
|
||||
|
||||
console.log("");
|
||||
console.log(" ─────────────────────────────────────────────");
|
||||
console.log(" Deprecated Modules (PR-1 Legacy Inventory):");
|
||||
console.log(" ─────────────────────────────────────────────");
|
||||
console.log("");
|
||||
|
||||
for (const row of legacyRows) {
|
||||
console.log(` Module: ${row.module}`);
|
||||
console.log(` Status: ${row.status}`);
|
||||
console.log(` Replacement: ${row.replacement}`);
|
||||
console.log(` Migration Window: ${row.migrationWindow}`);
|
||||
console.log(` Removal Target: ${row.removalTarget}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// Migration timeline
|
||||
console.log(" ─────────────────────────────────────────────");
|
||||
console.log(" Migration Timeline:");
|
||||
console.log("");
|
||||
console.log(" Day 0 2026-06-04 Freeze active");
|
||||
console.log(" Day 30 2026-07-04 Legacy warnings active");
|
||||
console.log(" Day 60 2026-08-04 Honcho/LanceDB read-only cutoff");
|
||||
console.log(" Day 90 2026-09-04 All legacy modules removed");
|
||||
console.log("");
|
||||
|
||||
// Per-module key list for scripting
|
||||
console.log(" Deprecated keys:");
|
||||
for (const w of allWarnings) {
|
||||
const info = LEGACY_REGISTRY[w.key];
|
||||
const target = info ? info.removalTarget : "N/A";
|
||||
console.log(` • ${w.key} (removal: ${target})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
return { warnings: allWarnings, results: checkResults };
|
||||
}
|
||||
|
||||
// Run if called as main
|
||||
if (process.argv[1]?.includes("check-deprecations")) {
|
||||
const result = runAllChecks();
|
||||
process.exit(0); // Never fail on deprecation warnings
|
||||
}
|
||||
|
||||
export { runAllChecks, loadConfig, LEGACY_REGISTRY, getLegacyInfo, formatLegacyTable,
|
||||
checkActiveMemoryBlocking, checkDreamingRemPhase, checkDreamDiary,
|
||||
checkQmdReference, checkHonchoLanceDB, checkMemoryWiki,
|
||||
checkCommitmentsAutoInfer, checkGroundedBackfill };
|
||||
@@ -0,0 +1,776 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Contract Consistency Test
|
||||
*
|
||||
* 验证一个由 Pipeline 生成的项目中:
|
||||
* 1. Schema 字段 100% 匹配 Contract 定义
|
||||
* 2. Types 字段名(camelCase)100% 匹配 Contract 的 fieldMapping
|
||||
* 3. Routes 的 DTO 类型 100% 匹配 Contract
|
||||
* 4. Services 的 SQL 列名 100% 匹配 Contract
|
||||
* 5. Tests 使用的字段名 100% 匹配 Contract
|
||||
*
|
||||
* 验证失败则 exit(1),成功 exit(0)。
|
||||
* 输出 contract-consistency-report.md。
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/contract-consistency-test.mjs --project <dir> [--prd <path>] [--arch <path>]
|
||||
*
|
||||
* Options:
|
||||
* --project <dir> 项目根目录
|
||||
* --prd <path> PRD JSON 文件路径(用于生成 Contract)
|
||||
* --arch <path> Architecture JSON 文件路径(用于生成 Contract)
|
||||
* --help 显示帮助
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createContract, toCamel, toSnake } from "./model-contract.mjs";
|
||||
|
||||
const __dirname = dirname(resolve(fileURLToPath(import.meta.url)));
|
||||
const WORKSPACE = resolve(__dirname, "..");
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 1. Test Infrastructure
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
class ContractConsistencyTest {
|
||||
constructor(contract) {
|
||||
this.contract = contract;
|
||||
this.failures = [];
|
||||
this.warnings = [];
|
||||
this.checks = { total: 0, passed: 0, failed: 0 };
|
||||
}
|
||||
|
||||
fail(section, message) {
|
||||
this.failures.push({ section, message });
|
||||
this.checks.failed++;
|
||||
this.checks.total++;
|
||||
}
|
||||
|
||||
pass() {
|
||||
this.checks.passed++;
|
||||
this.checks.total++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find files matching a glob in a directory.
|
||||
*/
|
||||
findFiles(dir, pattern) {
|
||||
const results = [];
|
||||
if (!existsSync(dir)) return results;
|
||||
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = resolve(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
// Skip node_modules and dist
|
||||
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".next" || entry.name === "data") continue;
|
||||
results.push(...this.findFiles(fullPath, pattern));
|
||||
} else if (pattern.test(entry.name)) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 2. Check 1: Schema → Contract
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function checkSchemaConsistency(test) {
|
||||
const { contract } = test;
|
||||
const entities = contract.entities;
|
||||
|
||||
console.log("── Check 1: Database Schema ↔ Contract ──");
|
||||
|
||||
// Find schema files
|
||||
const projectDir = process.env.PROJECT_DIR || ".";
|
||||
const schemaFiles = [
|
||||
...test.findFiles(resolve(projectDir, "backend/src/db"), /schema\.ts$/),
|
||||
...test.findFiles(resolve(projectDir, "src/db"), /schema\.ts$/),
|
||||
...test.findFiles(resolve(projectDir, "apps/api/src/db"), /schema\.ts$/),
|
||||
...test.findFiles(resolve(projectDir, "fullstack/apps/api/src/db"), /schema\.ts$/),
|
||||
...test.findFiles(projectDir, /schema\.prisma$/),
|
||||
];
|
||||
|
||||
if (schemaFiles.length === 0) {
|
||||
test.fail("Schema", "No schema file found (src/db/schema.ts or schema.prisma)");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const schemaFile of schemaFiles) {
|
||||
console.log(` Schema file: ${schemaFile}`);
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(schemaFile, "utf-8");
|
||||
} catch (e) {
|
||||
test.fail("Schema", `Cannot read ${schemaFile}: ${e.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entity of entities) {
|
||||
console.log(` Checking entity: ${entity.name} (${entity.table})`);
|
||||
|
||||
// Verify the table exists in the schema
|
||||
const tableRegex = new RegExp(`CREATE TABLE IF NOT EXISTS ${entity.table} \\(([\\s\\S]+?)\\);`, "si");
|
||||
const tableMatch = content.match(tableRegex);
|
||||
|
||||
if (!tableMatch) {
|
||||
// Try without CREATE TABLE (for Prisma/simpler formats)
|
||||
const altRegex = new RegExp(`(?:CREATE TABLE|model)\\s+(?:IF NOT EXISTS\\s+)?['"]?${entity.table}['"]?`, "i");
|
||||
if (!altRegex.test(content)) {
|
||||
test.warnings.push({ section: "Schema", message: `Table '${entity.table}' not found in schema` });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract column definitions
|
||||
let columns;
|
||||
if (tableMatch) {
|
||||
const body = tableMatch[1];
|
||||
columns = body
|
||||
.split(",")
|
||||
.map(c => c.trim())
|
||||
.filter(c => c.length > 0 && !c.startsWith("--"))
|
||||
.map(c => {
|
||||
// Handle DEFAULT (...) expressions with nested parens
|
||||
const cleanCol = c.replace(/DEFAULT\s*\([^)]*\)/gi, "").replace(/^\\n\s*/, "").trim();
|
||||
const parts = cleanCol.split(/\s+/);
|
||||
return { name: parts[0], raw: c };
|
||||
});
|
||||
} else {
|
||||
columns = [];
|
||||
}
|
||||
|
||||
const columnNames = new Set(columns.map(c => c.name));
|
||||
|
||||
for (const field of entity.fields) {
|
||||
test.pass();
|
||||
if (field.isSecret) continue; // Secret fields may not be in output types
|
||||
|
||||
if (!columnNames.has(field.name)) {
|
||||
test.fail(
|
||||
`Schema/${entity.table}`,
|
||||
`Field '${field.name}' defined in Contract but MISSING from schema table '${entity.table}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for extra columns not in contract (warn only)
|
||||
for (const col of columns) {
|
||||
const hasField = entity.fields.some(f => f.name === col.name);
|
||||
if (!hasField && !col.raw.toUpperCase().includes("FOREIGN KEY") &&
|
||||
!col.raw.toUpperCase().includes("PRIMARY KEY") &&
|
||||
!col.raw.toUpperCase().includes("CONSTRAINT") &&
|
||||
!col.raw.toUpperCase().includes("UNIQUE(") &&
|
||||
!col.raw.toUpperCase().includes("CHECK(") &&
|
||||
col.name !== "" && !col.raw.startsWith(")")) {
|
||||
test.warnings.push({
|
||||
section: `Schema/${entity.table}`,
|
||||
message: `Column '${col.name}' found in schema but NOT in Contract for table '${entity.table}'`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Schema checks: ${test.checks.passed - (test.checks.passed > 0 ? 0 : 0)} passed, ${test.checks.failed} failures`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 3. Check 2: Types → Contract
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function checkTypesConsistency(test) {
|
||||
const { contract } = test;
|
||||
const projectDir = process.env.PROJECT_DIR || ".";
|
||||
|
||||
console.log("── Check 2: TypeScript Types ↔ Contract ──");
|
||||
|
||||
const typeFiles = [
|
||||
...test.findFiles(resolve(projectDir, "backend/src/types"), /\.ts$/),
|
||||
...test.findFiles(resolve(projectDir, "src/types"), /\.ts$/),
|
||||
...test.findFiles(resolve(projectDir, "apps/api/src/types"), /\.ts$/),
|
||||
...test.findFiles(resolve(projectDir, "apps/api/packages/shared-types"), /\.ts$/),
|
||||
...test.findFiles(resolve(projectDir, "packages/shared-types"), /\.ts$/),
|
||||
].filter(f => !f.endsWith(".d.ts") || f.endsWith("fastify.d.ts"));
|
||||
|
||||
if (typeFiles.length === 0) {
|
||||
test.fail("Types", "No TypeScript type files found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a set of all expected camelCase field names from the contract
|
||||
const expectedFields = new Map(); // entityName → Set of field names
|
||||
for (const entity of contract.entities) {
|
||||
const fieldSet = new Set();
|
||||
for (const field of entity.fields) {
|
||||
if (field.isSecret) continue;
|
||||
const camel = toCamel(contract, field.name);
|
||||
fieldSet.add(camel);
|
||||
}
|
||||
expectedFields.set(entity.name, fieldSet);
|
||||
}
|
||||
|
||||
for (const typeFile of typeFiles) {
|
||||
console.log(` Types file: ${typeFile}`);
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(typeFile, "utf-8");
|
||||
} catch (e) {
|
||||
test.fail("Types", `Cannot read ${typeFile}: ${e.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// For each entity, check its interface exists and has correct fields
|
||||
for (const entity of contract.entities) {
|
||||
const expected = expectedFields.get(entity.name);
|
||||
if (!expected) continue;
|
||||
|
||||
// Find the interface declaration
|
||||
const ifaceRegex = new RegExp(`(?:export\\s+)?interface\\s+${entity.name}\\s*(?:extends\\s+[^{]+)?\\s*\\{([^}]*)\\}`, "s");
|
||||
const match = content.match(ifaceRegex);
|
||||
|
||||
if (!match) {
|
||||
// Entity types might not be directly in this file (e.g. in shared-types)
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = match[1];
|
||||
const fieldLines = body
|
||||
.split("\n")
|
||||
.map(l => l.trim())
|
||||
.filter(l => l.length > 0 && !l.startsWith("//"));
|
||||
|
||||
const foundFields = new Set();
|
||||
for (const line of fieldLines) {
|
||||
// Extract field name: " username: string;" or " username?: string;"
|
||||
const fieldMatch = line.match(/^\s*(\w+)(\?)?\s*:/);
|
||||
if (fieldMatch) {
|
||||
foundFields.add(fieldMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Check each expected field exists
|
||||
for (const fieldName of expected) {
|
||||
test.pass();
|
||||
if (!foundFields.has(fieldName)) {
|
||||
test.fail(
|
||||
`Types/${entity.name}`,
|
||||
`Field '${fieldName}' (camelCase) expected in interface '${entity.name}' but MISSING from types file`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Warn about extra fields
|
||||
for (const found of foundFields) {
|
||||
if (!expected.has(found)) {
|
||||
test.warnings.push({
|
||||
section: `Types/${entity.name}`,
|
||||
message: `Field '${found}' found in interface '${entity.name}' but NOT in Contract`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check that all entities exist as interfaces in the types
|
||||
// Find ALL interface names declared
|
||||
for (const typeFile of typeFiles) {
|
||||
let content;
|
||||
try { content = readFileSync(typeFile, "utf-8"); } catch { continue; }
|
||||
for (const entity of contract.entities) {
|
||||
const ifaceRegex = new RegExp(`interface\\s+${entity.name}\\s*(?:extends|\\{)`, "s");
|
||||
if (!ifaceRegex.test(content)) {
|
||||
// Check if entity's table is users — User is a special type
|
||||
if (entity.table !== "users") {
|
||||
// It might be in another type file; we accumulate all
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 4. Check 3: Routes → Contract
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function checkRoutesConsistency(test) {
|
||||
const { contract } = test;
|
||||
const projectDir = process.env.PROJECT_DIR || ".";
|
||||
|
||||
console.log("── Check 3: Route DTO Types ↔ Contract ──");
|
||||
|
||||
const routeFiles = [
|
||||
...test.findFiles(resolve(projectDir, "src/routes"), /\.ts$/),
|
||||
...test.findFiles(resolve(projectDir, "apps/api/src/routes"), /\.ts$/),
|
||||
];
|
||||
|
||||
if (routeFiles.length === 0) {
|
||||
test.warnings.push({ section: "Routes", message: "No route files found, skipping route checks" });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const routeFile of routeFiles) {
|
||||
console.log(` Route file: ${routeFile}`);
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(routeFile, "utf-8");
|
||||
} catch (e) { continue; }
|
||||
|
||||
// Check auth routes use contract auth config
|
||||
// Check CRUD routes use correct entity field names
|
||||
for (const entity of contract.entities) {
|
||||
if (entity.table === "users") continue; // Skip User — handled by auth routes
|
||||
|
||||
const routeName = entity.table;
|
||||
if (routeFile.includes(routeName) || routeFile.endsWith(`${routeName}.ts`)) {
|
||||
// Verify CreateInput and UpdateInput types are used
|
||||
const pascalName = entity.name;
|
||||
|
||||
const hasCreateInput = new RegExp(`Create${pascalName}Input`).test(content);
|
||||
const hasUpdateInput = new RegExp(`Update${pascalName}Input`).test(content);
|
||||
|
||||
if (!hasCreateInput) {
|
||||
test.fail(
|
||||
`Routes/${routeName}`,
|
||||
`Route for '${routeName}' should use Create${pascalName}Input type`
|
||||
);
|
||||
} else { test.pass(); }
|
||||
|
||||
if (!hasUpdateInput) {
|
||||
test.fail(
|
||||
`Routes/${routeName}`,
|
||||
`Route for '${routeName}' should use Update${pascalName}Input type`
|
||||
);
|
||||
} else { test.pass(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 5. Check 4: Services → Contract
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function checkServicesConsistency(test) {
|
||||
const { contract } = test;
|
||||
const projectDir = process.env.PROJECT_DIR || ".";
|
||||
|
||||
console.log("── Check 4: Service SQL Columns ↔ Contract ──");
|
||||
|
||||
const serviceFiles = [
|
||||
...test.findFiles(resolve(projectDir, "src/services"), /\.ts$/),
|
||||
...test.findFiles(resolve(projectDir, "apps/api/src/services"), /\.ts$/),
|
||||
];
|
||||
|
||||
if (serviceFiles.length === 0) {
|
||||
test.warnings.push({ section: "Services", message: "No service files found, skipping service checks" });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const serviceFile of serviceFiles) {
|
||||
console.log(` Service file: ${serviceFile}`);
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(serviceFile, "utf-8");
|
||||
} catch (e) { continue; }
|
||||
|
||||
// Extract SQL column names from INSERT/SELECT statements
|
||||
const insertMatches = content.matchAll(/INSERT INTO\s+(\w+)\s*\(([^)]+)\)/gi);
|
||||
for (const match of insertMatches) {
|
||||
const tableName = match[1];
|
||||
const colStr = match[2];
|
||||
const columns = colStr.split(",").map(c => c.trim().replace(/"/g, ""));
|
||||
|
||||
// Find the entity for this table
|
||||
const entity = contract.entities.find(e => e.table === tableName);
|
||||
if (!entity) continue;
|
||||
|
||||
const contractCols = new Set(entity.fields.filter(f => !f.isSecret).map(f => f.name));
|
||||
|
||||
for (const col of columns) {
|
||||
test.pass();
|
||||
if (!contractCols.has(col)) {
|
||||
test.fail(
|
||||
`Services/${tableName}`,
|
||||
`SQL column '${col}' in INSERT statement not found in Contract for table '${tableName}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check SELECT statements too
|
||||
const selectMatches = content.matchAll(/SELECT\s+(.+?)\s+FROM\s+(\w+)/gi);
|
||||
for (const match of selectMatches) {
|
||||
const selectStr = match[1];
|
||||
const tableName = match[2];
|
||||
const entity = contract.entities.find(e => e.table === tableName);
|
||||
if (!entity || selectStr === "*") continue;
|
||||
|
||||
const selectCols = selectStr
|
||||
.split(",")
|
||||
.map(c => c.trim().split(/\s+as\s+/i)[0].trim())
|
||||
.filter(c => c.length > 0 && c !== "*");
|
||||
|
||||
const contractCols = new Set(entity.fields.filter(f => !f.isSecret).map(f => f.name));
|
||||
|
||||
for (const col of selectCols) {
|
||||
// Column might be aliased or use table prefix
|
||||
const bareCol = col.split(".").pop().replace(/"/g, "");
|
||||
if (!contractCols.has(bareCol)) {
|
||||
test.warnings.push({
|
||||
section: `Services/${tableName}`,
|
||||
message: `SQL selected column '${bareCol}' not found in Contract for table '${tableName}'`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 6. Check 5: Tests → Contract
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function checkTestsConsistency(test) {
|
||||
const { contract } = test;
|
||||
const projectDir = process.env.PROJECT_DIR || ".";
|
||||
|
||||
console.log("── Check 5: Test Payloads ↔ Contract ──");
|
||||
|
||||
const testFiles = [
|
||||
...test.findFiles(resolve(projectDir, "src/__tests__"), /\.test\.ts$/),
|
||||
...test.findFiles(resolve(projectDir, "apps/api/src/__tests__"), /\.test\.ts$/),
|
||||
...test.findFiles(projectDir, /\.test\.ts$/),
|
||||
];
|
||||
|
||||
if (testFiles.length === 0) {
|
||||
test.warnings.push({ section: "Tests", message: "No test files found, skipping test checks" });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const testFile of testFiles) {
|
||||
console.log(` Test file: ${testFile}`);
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(testFile, "utf-8");
|
||||
} catch (e) { continue; }
|
||||
|
||||
// Extract payload objects from .inject() calls
|
||||
const payloadRegex = /payload:\s*({[^}]+})/gs;
|
||||
const matches = [...content.matchAll(payloadRegex)];
|
||||
|
||||
for (const match of matches) {
|
||||
let payloadStr = match[1];
|
||||
try {
|
||||
// Try to parse as JSON (might have template literals, so this is approximate)
|
||||
// Extract field names using regex
|
||||
const fieldRegex = /(\w+)\s*:/g;
|
||||
const payloadFields = [];
|
||||
let fm;
|
||||
while ((fm = fieldRegex.exec(payloadStr)) !== null) {
|
||||
payloadFields.push(fm[1]);
|
||||
}
|
||||
|
||||
// Check if these fields are valid according to some entity
|
||||
const allContractFields = new Set();
|
||||
for (const entity of contract.entities) {
|
||||
for (const field of entity.fields) {
|
||||
if (!field.isSecret && !field.isAuto && !field.isPrimary) {
|
||||
allContractFields.add(toCamel(contract, field.name));
|
||||
allContractFields.add(field.name); // snake_case
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const pf of payloadFields) {
|
||||
if (!allContractFields.has(pf)) {
|
||||
// Allow special test fields
|
||||
if (["username", "password", "nickname", "token", "authorization"].includes(pf)) {
|
||||
continue;
|
||||
}
|
||||
test.warnings.push({
|
||||
section: `Tests/${testFile}`,
|
||||
message: `Test payload field '${pf}' not found in any Contract entity`
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON payload, skip
|
||||
}
|
||||
}
|
||||
|
||||
// Check for hardcoded fake userId values
|
||||
const fakeIdMatch = content.match(/"00000000-0000-0000-0000-000000000001"/);
|
||||
if (fakeIdMatch) {
|
||||
test.warnings.push({
|
||||
section: `Tests/${testFile}`,
|
||||
message: "Hardcoded fake userId found in test. Consider using a real registered user ID."
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 7. Check 6: Auth Configuration
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function checkAuthConsistency(test) {
|
||||
const { contract } = test;
|
||||
const projectDir = process.env.PROJECT_DIR || ".";
|
||||
|
||||
console.log("── Check 7: Auth Config ↔ Contract ──");
|
||||
|
||||
const authFiles = [
|
||||
...test.findFiles(resolve(projectDir, "src/routes"), /auth\.ts$/),
|
||||
...test.findFiles(resolve(projectDir, "apps/api/src/routes"), /auth\.ts$/),
|
||||
];
|
||||
|
||||
if (authFiles.length === 0) {
|
||||
test.warnings.push({ section: "Auth", message: "No auth routes found" });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const authFile of authFiles) {
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(authFile, "utf-8");
|
||||
} catch (e) { continue; }
|
||||
|
||||
// Check registration fields
|
||||
for (const field of contract.auth.registrationFields) {
|
||||
test.pass();
|
||||
if (!content.includes(field)) {
|
||||
test.fail("Auth", `Registration field '${field}' from contract.auth.registrationFields not found in auth routes`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check JWT payload fields
|
||||
for (const field of contract.auth.jwtPayload) {
|
||||
test.pass();
|
||||
if (!content.includes(field)) {
|
||||
test.fail("Auth", `JWT payload field '${field}' from contract.auth.jwtPayload not found in auth routes`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check password policy (minLength)
|
||||
const pwPolicy = contract.auth.passwordPolicy;
|
||||
if (pwPolicy.minLength) {
|
||||
const minLenRegex = new RegExp(`password\\.length\\s*<\\s*${pwPolicy.minLength}`);
|
||||
if (minLenRegex.test(content)) {
|
||||
test.pass();
|
||||
} else {
|
||||
test.fail("Auth", `Password minLength policy (${pwPolicy.minLength}) not enforced in auth routes`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check password_hash column is used (not a plain password column)
|
||||
test.pass();
|
||||
if (!content.includes("password_hash") && !content.includes("passwordHash")) {
|
||||
test.fail("Auth", "Auth routes should use 'password_hash' column (not plain password)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 8. Main Runner
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateReport(test, contract) {
|
||||
const lines = [];
|
||||
|
||||
lines.push("# Contract Consistency Report");
|
||||
lines.push("");
|
||||
lines.push(`**Generated**: ${new Date().toISOString()}`);
|
||||
lines.push(`**Project**: ${contract.projectName}`);
|
||||
lines.push(`**Domain**: ${contract.domain}`);
|
||||
lines.push(`**Entities**: ${contract.entities.length}`);
|
||||
lines.push("");
|
||||
lines.push("## Summary");
|
||||
lines.push("");
|
||||
lines.push(`| Metric | Value |`);
|
||||
lines.push(`|--------|-------|`);
|
||||
lines.push(`| Total Checks | ${test.checks.total} |`);
|
||||
lines.push(`| Passed | ${test.checks.passed} |`);
|
||||
lines.push(`| Failed | ${test.checks.failed} |`);
|
||||
lines.push(`| Warnings | ${test.warnings.length} |`);
|
||||
lines.push(`| **Result** | **${test.checks.failed === 0 ? "✅ PASS" : "❌ FAIL"}** |`);
|
||||
lines.push("");
|
||||
|
||||
if (test.failures.length > 0) {
|
||||
lines.push("## ❌ Failures");
|
||||
lines.push("");
|
||||
for (const f of test.failures) {
|
||||
lines.push(`- **${f.section}**: ${f.message}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (test.warnings.length > 0) {
|
||||
lines.push("## ⚠️ Warnings");
|
||||
lines.push("");
|
||||
for (const w of test.warnings) {
|
||||
lines.push(`- **${w.section}**: ${w.message}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("## Contract Entities");
|
||||
lines.push("");
|
||||
for (const entity of contract.entities) {
|
||||
lines.push(`### ${entity.name} (\`${entity.table}\`)`);
|
||||
lines.push(`*${entity.description}*`);
|
||||
lines.push("");
|
||||
lines.push("| Field | Type | Required | Notes |");
|
||||
lines.push("|-------|------|----------|-------|");
|
||||
for (const field of entity.fields) {
|
||||
const notes = [];
|
||||
if (field.isPrimary) notes.push("PK");
|
||||
if (field.isAuto) notes.push("auto");
|
||||
if (field.isSecret) notes.push("secret");
|
||||
if (field.unique) notes.push("unique");
|
||||
if (field.enum) notes.push(`enum: ${field.enum.join("|")}`);
|
||||
if (field.defaultValue !== undefined) notes.push(`default: ${field.defaultValue}`);
|
||||
if (field.fkEntity) notes.push(`FK→${field.fkEntity}.${field.fkColumn}`);
|
||||
lines.push(`| \`${field.name}\` | ${field.type} | ${field.required ? "✓" : ""} | ${notes.join(", ")} |`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Field mapping reference
|
||||
lines.push("## Field Mapping");
|
||||
lines.push("");
|
||||
lines.push("| snake_case | camelCase |");
|
||||
lines.push("|------------|-----------|");
|
||||
for (const [snake, camel] of Object.entries(contract.fieldMapping.snakeToCamel)) {
|
||||
if (snake !== camel) {
|
||||
lines.push(`| \`${snake}\` | \`${camel}\` |`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
const report = lines.join("\n");
|
||||
|
||||
// Write report
|
||||
const reportPath = resolve(WORKSPACE, "contract-consistency-report.md");
|
||||
mkdirSync(dirname(reportPath), { recursive: true });
|
||||
writeFileSync(reportPath, report);
|
||||
console.log(`\nReport written to: ${reportPath}`);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let projectDir = null, prdPath = null, archPath = null;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--project" && args[i + 1]) {
|
||||
projectDir = resolve(args[++i]);
|
||||
} else if (args[i] === "--prd" && args[i + 1]) {
|
||||
prdPath = resolve(args[++i]);
|
||||
} else if (args[i] === "--arch" && args[i + 1]) {
|
||||
archPath = resolve(args[++i]);
|
||||
} else if (args[i] === "--help" || args[i] === "-h") {
|
||||
console.log(`
|
||||
Contract Consistency Test
|
||||
|
||||
Usage:
|
||||
node scripts/contract-consistency-test.mjs --project <dir> [--prd <path>] [--arch <path>]
|
||||
|
||||
Options:
|
||||
--project <dir> 项目根目录
|
||||
--prd <path> PRD JSON 文件路径
|
||||
--arch <path> Architecture JSON 文件路径
|
||||
--help 显示帮助
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectDir) {
|
||||
console.error("Error: --project <dir> is required. Use --help.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Set global PROJECT_DIR for check functions
|
||||
process.env.PROJECT_DIR = projectDir;
|
||||
|
||||
// Load PRD and Architecture if available, or create a minimal contract
|
||||
let prd = null, arch = null;
|
||||
|
||||
if (prdPath && existsSync(prdPath)) {
|
||||
try {
|
||||
prd = JSON.parse(readFileSync(prdPath, "utf-8"));
|
||||
} catch (e) {
|
||||
console.error(`Warning: Failed to load PRD: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (archPath && existsSync(archPath)) {
|
||||
try {
|
||||
arch = JSON.parse(readFileSync(archPath, "utf-8"));
|
||||
} catch (e) {
|
||||
console.error(`Warning: Failed to load Architecture: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If no PRD/arch, try to derive contract from generated project
|
||||
if (!prd && !arch) {
|
||||
// Check if project has a prd.json or arch.json
|
||||
const projectPrd = resolve(projectDir, "prd.json");
|
||||
const projectArch = resolve(projectDir, "arch.json");
|
||||
|
||||
if (existsSync(projectPrd)) {
|
||||
try { prd = JSON.parse(readFileSync(projectPrd, "utf-8")); } catch {}
|
||||
}
|
||||
if (existsSync(projectArch)) {
|
||||
try { arch = JSON.parse(readFileSync(projectArch, "utf-8")); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// If still no PRD, try to detect domain from project structure
|
||||
if (!prd) {
|
||||
prd = { projectName: "ContractMgmt", domain: "enterprise", features: [], pages: [], apiRequirements: [] };
|
||||
}
|
||||
|
||||
// Create contract
|
||||
const contract = createContract(prd, arch);
|
||||
|
||||
console.log(`\n═══ Contract Consistency Test ═══`);
|
||||
console.log(`Project: ${contract.projectName}`);
|
||||
console.log(`Domain: ${contract.domain}`);
|
||||
console.log(`Entities: ${contract.entities.map(e => e.name).join(", ")}`);
|
||||
console.log(`Project Dir: ${projectDir}\n`);
|
||||
|
||||
// Initialize test
|
||||
const test = new ContractConsistencyTest(contract);
|
||||
|
||||
// Run checks
|
||||
try { checkSchemaConsistency(test); } catch (e) { test.fail("Schema", `Error: ${e.message}`); }
|
||||
try { checkTypesConsistency(test); } catch (e) { test.fail("Types", `Error: ${e.message}`); }
|
||||
try { checkRoutesConsistency(test); } catch (e) { test.fail("Routes", `Error: ${e.message}`); }
|
||||
try { checkServicesConsistency(test); } catch (e) { test.fail("Services", `Error: ${e.message}`); }
|
||||
try { checkTestsConsistency(test); } catch (e) { test.fail("Tests", `Error: ${e.message}`); }
|
||||
try { checkAuthConsistency(test); } catch (e) { test.fail("Auth", `Error: ${e.message}`); }
|
||||
|
||||
// Generate report
|
||||
const report = generateReport(test, contract);
|
||||
|
||||
// Print summary
|
||||
console.log(`\n═══ Results ═══`);
|
||||
console.log(`Total: ${test.checks.total} | Passed: ${test.checks.passed} | Failed: ${test.checks.failed} | Warnings: ${test.warnings.length}`);
|
||||
console.log(`Result: ${test.checks.failed === 0 ? "✅ PASS" : "❌ FAIL"}`);
|
||||
|
||||
// Exit with appropriate code
|
||||
process.exit(test.checks.failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 合同管理工具 — 完整 E2E 生成 + 验证
|
||||
* 使用 Certified Software Generator v1.1 的标准 Pipeline
|
||||
* 不修改任何 Generator 源码
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(resolve(fileURLToPath(import.meta.url)));
|
||||
const WORKSPACE = resolve(__dirname, "..");
|
||||
const SCRIPTS = resolve(WORKSPACE, "scripts");
|
||||
const B = resolve(WORKSPACE, ".benchmark", "contract-mgmt");
|
||||
|
||||
const INPUT = "开发一个合同管理桌面工具,支持合同创建、合同审批、合同到期提醒、客户管理、合同金额统计、操作日志";
|
||||
|
||||
mkdirSync(B, { recursive: true });
|
||||
|
||||
function cmd(c, opts = {}) {
|
||||
try {
|
||||
return execSync(c, { cwd: WORKSPACE, encoding: "utf8", ...opts, stdio: opts.stdio ?? "pipe" });
|
||||
} catch (e) {
|
||||
return { error: e.message, stderr: e.stderr?.toString() || "", stdout: e.stdout?.toString() || "" };
|
||||
}
|
||||
}
|
||||
|
||||
function runAgent(script, args) {
|
||||
const t0 = Date.now();
|
||||
const result = cmd(`node ${join(SCRIPTS, script)} ${args}`);
|
||||
const ms = Date.now() - t0;
|
||||
if (result.error) {
|
||||
try { return { ms, ...JSON.parse(result.stdout?.trim() || "{}") }; } catch { return { ms, error: result.error }; }
|
||||
}
|
||||
try { return { ms, ...JSON.parse(result.trim()) }; } catch { return { ms, raw: result }; }
|
||||
}
|
||||
|
||||
function countFiles(dir) {
|
||||
try {
|
||||
return parseInt(execSync(`find ${dir} -type f 2>/dev/null | wc -l`, { encoding: "utf8", cwd: WORKSPACE }).trim(), 10);
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
// Validation functions (same as e2e-benchmark-v1.mjs)
|
||||
function validateFrontend(dir) {
|
||||
if (!existsSync(dir)) return { pass: false, reason: "dir missing" };
|
||||
const checks = {
|
||||
packageJson: existsSync(join(dir, "package.json")),
|
||||
appDir: existsSync(join(dir, "app")),
|
||||
services: existsSync(join(dir, "services")) || existsSync(join(dir, "src", "services")),
|
||||
types: existsSync(join(dir, "types")) || existsSync(join(dir, "src", "types")),
|
||||
};
|
||||
const fileCount = countFiles(dir);
|
||||
return { pass: checks.packageJson && checks.appDir && fileCount > 10, fileCount, ...checks };
|
||||
}
|
||||
|
||||
function validateBackend(dir) {
|
||||
if (!existsSync(dir)) return { pass: false, reason: "dir missing" };
|
||||
const checks = {
|
||||
packageJson: existsSync(join(dir, "package.json")),
|
||||
routes: existsSync(join(dir, "src", "routes")),
|
||||
services: existsSync(join(dir, "src", "services")),
|
||||
auth: existsSync(join(dir, "src", "routes", "auth.ts")) || existsSync(join(dir, "src", "routes", "auth-route.ts")),
|
||||
db: existsSync(join(dir, "src", "db")) || existsSync(join(dir, "src", "schema")),
|
||||
};
|
||||
const fileCount = countFiles(dir);
|
||||
return { pass: checks.packageJson && checks.routes && checks.services && fileCount > 10, fileCount, ...checks };
|
||||
}
|
||||
|
||||
function validateFullstack(dir) {
|
||||
if (!existsSync(dir)) return { pass: false, reason: "dir missing" };
|
||||
const checks = {
|
||||
packageJson: existsSync(join(dir, "package.json")),
|
||||
web: existsSync(join(dir, "apps", "web")),
|
||||
api: existsSync(join(dir, "apps", "api")),
|
||||
shared: existsSync(join(dir, "packages")),
|
||||
};
|
||||
const fileCount = countFiles(dir);
|
||||
return { pass: checks.packageJson && checks.web && checks.api && fileCount > 30, fileCount, ...checks };
|
||||
}
|
||||
|
||||
function validateElectron(dir) {
|
||||
if (!existsSync(dir)) return { pass: false, reason: "dir missing" };
|
||||
const checks = {
|
||||
packageJson: existsSync(join(dir, "package.json")),
|
||||
main: existsSync(join(dir, "electron", "main.ts")),
|
||||
preload: existsSync(join(dir, "electron", "preload.ts")),
|
||||
ipc: existsSync(join(dir, "electron", "ipc.ts")),
|
||||
yml: existsSync(join(dir, "electron-builder.yml")),
|
||||
};
|
||||
const fileCount = countFiles(dir);
|
||||
return { pass: checks.packageJson && checks.main && checks.preload && fileCount >= 8, fileCount, ...checks };
|
||||
}
|
||||
|
||||
function validateRelease(dir) {
|
||||
if (!existsSync(dir)) return { pass: false, reason: "dir missing" };
|
||||
const rDir = join(dir, "release");
|
||||
const checks = {
|
||||
versionJson: existsSync(join(rDir, "version.json")),
|
||||
manifest: existsSync(join(rDir, "manifests", "manifest.json")),
|
||||
checksums: existsSync(join(rDir, "checksums", "checksums.txt")),
|
||||
releaseNotes: existsSync(join(rDir, "release-notes", "release-notes.md")),
|
||||
buildInfo: existsSync(join(rDir, "build-info.json")),
|
||||
};
|
||||
if (checks.checksums) {
|
||||
const lines = readFileSync(join(rDir, "checksums", "checksums.txt"), "utf8").trim().split("\n").filter(l => l.length > 0);
|
||||
checks.checksumCount = lines.length;
|
||||
checks.checksumsValid = lines.every(l => l.split(/\s+/)[0]?.length === 64);
|
||||
}
|
||||
if (checks.buildInfo) {
|
||||
try {
|
||||
const bi = JSON.parse(readFileSync(join(rDir, "build-info.json"), "utf8"));
|
||||
checks.buildInfoValid = !!(bi.nodeVersion && bi.generatorVersion);
|
||||
} catch { checks.buildInfoValid = false; }
|
||||
}
|
||||
const fileCount = countFiles(dir);
|
||||
return { pass: checks.versionJson && checks.manifest && checks.checksums && checks.releaseNotes && checks.buildInfo, fileCount, ...checks };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// MAIN
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
console.log("═".repeat(60));
|
||||
console.log(" 合同管理工具 — Certified Generator v1.1 E2E 验证");
|
||||
console.log("═".repeat(60));
|
||||
console.log(`\n需求: ${INPUT}\n`);
|
||||
|
||||
// SF-01: PRD
|
||||
console.log("▸ SF-01 Project Intake...");
|
||||
const s1 = runAgent("project-intake-agent.mjs", `--input "${INPUT}" --output "${join(B, "prd.json")}"`);
|
||||
console.log(` ✓ PRD: ${s1.projectName || "?"} (${s1.ms}ms) ${s1.error ? "❌ " + s1.error : "✅"}`);
|
||||
|
||||
// SF-02: Architecture
|
||||
console.log("▸ SF-02 Architecture...");
|
||||
const s2 = runAgent("architecture-agent.mjs", `--input "${join(B, "prd.json")}" --output "${join(B, "arch.json")}"`);
|
||||
console.log(` ✓ Architecture: ${s2.moduleCount || "?"} modules (${s2.ms}ms) ${s2.error ? "❌ " + s2.error : "✅"}`);
|
||||
|
||||
// SF-03: Frontend
|
||||
console.log("▸ SF-03 Frontend Builder...");
|
||||
const feOut = join(B, "frontend");
|
||||
const s3 = runAgent("frontend-builder-agent.mjs", `--prd "${join(B, "prd.json")}" --arch "${join(B, "arch.json")}" --output "${feOut}"`);
|
||||
const feVal = validateFrontend(feOut);
|
||||
console.log(` ✓ Frontend: ${feVal.fileCount} files (${s3.ms}ms) ${feVal.pass ? "✅" : "❌"}`);
|
||||
|
||||
// SF-04: Backend
|
||||
console.log("▸ SF-04 Backend Builder...");
|
||||
const beOut = join(B, "backend");
|
||||
const s4 = runAgent("backend-builder-agent.mjs", `--prd "${join(B, "prd.json")}" --arch "${join(B, "arch.json")}" --output "${beOut}"`);
|
||||
const beVal = validateBackend(beOut);
|
||||
console.log(` ✓ Backend: ${beVal.fileCount} files (${s4.ms}ms) ${beVal.pass ? "✅" : "❌"}`);
|
||||
|
||||
// SF-05: Fullstack
|
||||
console.log("▸ SF-05 Fullstack Composer...");
|
||||
const fsOut = join(B, "fullstack");
|
||||
const s5 = runAgent("fullstack-composer-agent.mjs", `--prd "${join(B, "prd.json")}" --arch "${join(B, "arch.json")}" --output "${fsOut}"`);
|
||||
const fsVal = validateFullstack(fsOut);
|
||||
console.log(` ✓ Fullstack: ${fsVal.fileCount} files (${s5.ms}ms) ${fsVal.pass ? "✅" : "❌"}`);
|
||||
|
||||
// SF-06: Electron
|
||||
console.log("▸ SF-06 Electron Builder...");
|
||||
const elOut = join(B, "electron");
|
||||
const s6 = runAgent("electron-builder-agent.mjs", `--input "${fsOut}" --output "${elOut}" --prd "${join(B, "prd.json")}"`);
|
||||
const elVal = validateElectron(elOut);
|
||||
console.log(` ✓ Electron: ${elVal.fileCount} files (${s6.ms}ms) ${elVal.pass ? "✅" : "❌"}`);
|
||||
|
||||
// SF-07: Release
|
||||
console.log("▸ SF-07 Release Builder...");
|
||||
const rlOut = join(B, "release");
|
||||
const s7 = runAgent("release-builder-agent.mjs", `--input "${fsOut}" --output "${rlOut}"`);
|
||||
const rlVal = validateRelease(rlOut);
|
||||
console.log(` ✓ Release: ${rlVal.fileCount} files (${s7.ms}ms) ${rlVal.pass ? "✅" : "❌"}`);
|
||||
|
||||
// Summary
|
||||
const totalMs = s1.ms + s2.ms + s3.ms + s4.ms + s5.ms + s6.ms + s7.ms;
|
||||
const totalFiles = (s3.stats?.totalFiles || feVal.fileCount) + (s4.stats?.totalFiles || beVal.fileCount) + (s5.stats?.totalFiles || fsVal.fileCount) + (s6.stats?.totalFiles || elVal.fileCount) + (s7.stats?.totalFiles || rlVal.fileCount);
|
||||
const allPass = feVal.pass && beVal.pass && fsVal.pass && elVal.pass && rlVal.pass;
|
||||
|
||||
console.log("\n" + "═".repeat(60));
|
||||
console.log(` 总计: ${totalFiles} files | ${totalMs}ms | ${allPass ? "✅ ALL PASS" : "❌ HAS FAILURES"}`);
|
||||
console.log("═".repeat(60));
|
||||
|
||||
// Output summary as JSON
|
||||
const summary = {
|
||||
input: INPUT,
|
||||
projectName: s1.projectName,
|
||||
domain: s1.domain,
|
||||
stages: {
|
||||
prd: { ms: s1.ms, error: s1.error },
|
||||
arch: { ms: s2.ms, modules: s2.moduleCount, error: s2.error },
|
||||
frontend: { ms: s3.ms, files: feVal.fileCount, pass: feVal.pass, error: s3.error },
|
||||
backend: { ms: s4.ms, files: beVal.fileCount, pass: beVal.pass, error: s4.error },
|
||||
fullstack: { ms: s5.ms, files: fsVal.fileCount, pass: fsVal.pass, error: s5.error },
|
||||
electron: { ms: s6.ms, files: elVal.fileCount, pass: elVal.pass, error: s6.error },
|
||||
release: { ms: s7.ms, files: rlVal.fileCount, pass: rlVal.pass, error: s7.error },
|
||||
},
|
||||
totalMs,
|
||||
totalFiles,
|
||||
allPass,
|
||||
outputDir: B,
|
||||
};
|
||||
|
||||
writeFileSync(join(B, "summary.json"), JSON.stringify(summary, null, 2));
|
||||
console.log(`\nSummary: ${join(B, "summary.json")}`);
|
||||
@@ -0,0 +1,873 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Domain Benchmark Suite
|
||||
*
|
||||
* 验证现有系统的泛化能力:
|
||||
* project-intake-agent → architecture-agent
|
||||
* → frontend-builder-agent → backend-builder-agent → fullstack-composer-agent
|
||||
*
|
||||
* 对 10 个领域生成完整项目,验证 Build + API,输出 benchmark-report.md
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/domain-benchmark.mjs [--domain <name>] [--skip-build] [--skip-api]
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(resolve(fileURLToPath(import.meta.url)));
|
||||
const WORKSPACE = resolve(__dirname, "..");
|
||||
const BENCH_ROOT = resolve(WORKSPACE, ".benchmark");
|
||||
const SCRIPTS = resolve(WORKSPACE, "scripts");
|
||||
|
||||
const REPORT_PATH = resolve(WORKSPACE, "benchmark-report.md");
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Domain Definitions
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
const DOMAINS = [
|
||||
{ id: "petcare", input: "做一个宠物护理管理平台,宠物主人可以管理宠物档案、健康日程、日常记录和成长相册" },
|
||||
{ id: "crm", input: "做一个客户关系管理系统,支持客户管理、销售漏斗、跟进记录和数据分析仪表盘" },
|
||||
{ id: "inventory", input: "做一个库存管理系统,支持商品入库出库、库存盘点、供应商管理和库存预警" },
|
||||
{ id: "ticket", input: "做一个工单系统,支持工单创建、分配、处理流程、优先级管理和工单归档" },
|
||||
{ id: "blog-cms", input: "做一个博客内容管理系统,支持文章发布、分类标签、评论管理和媒体库" },
|
||||
{ id: "project-mgmt", input: "做一个项目管理系统,支持项目看板、任务分配、甘特图和团队协作" },
|
||||
{ id: "hr", input: "做一个人力资源管理系统,支持员工档案、考勤管理、招聘流程和绩效评估" },
|
||||
{ id: "asset", input: "做一个固定资产管理系统,支持资产登记、领用归还、折旧计算和盘点统计" },
|
||||
{ id: "course", input: "做一个在线课程管理系统,支持课程发布、章节管理、学员进度和作业批改" },
|
||||
{ id: "appointment", input: "做一个预约管理系统,支持服务项目、时间段预约、客户通知和预约统计" },
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function cmd(c, opts = {}) {
|
||||
try {
|
||||
return execSync(c, { cwd: WORKSPACE, encoding: "utf8", ...opts, stdio: opts.stdio ?? "pipe" });
|
||||
} catch (e) {
|
||||
return { error: e.message, stderr: e.stderr?.toString() || "", stdout: e.stdout?.toString() || "" };
|
||||
}
|
||||
}
|
||||
|
||||
function runAgent(agentPath, args) {
|
||||
const result = cmd(`node ${agentPath} ${args}`);
|
||||
if (result.error) {
|
||||
// Try to extract JSON from stdout even on error
|
||||
try { return JSON.parse(result.stdout?.trim() || "{}"); } catch { return { error: result.error, stderr: result.stderr }; }
|
||||
}
|
||||
try { return JSON.parse(result.trim()); } catch { return { raw: result }; }
|
||||
}
|
||||
|
||||
function rmDirSafe(dir) {
|
||||
try { if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
function listFilesRecursive(dir, acc = []) {
|
||||
if (!existsSync(dir)) return acc;
|
||||
const entries = readFileSync(dir)?.toString?.(); // not for dirs
|
||||
const { readdirSync, statSync } = require("node:fs") || {};
|
||||
// Use shell for simplicity
|
||||
const result = execSync(`find ${dir} -type f | head -500`, { encoding: "utf8", cwd: WORKSPACE });
|
||||
return result.trim().split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
function countFiles(dir) {
|
||||
try {
|
||||
const r = execSync(`find ${dir} -type f 2>/dev/null | wc -l`, { encoding: "utf8", cwd: WORKSPACE });
|
||||
return parseInt(r.trim(), 10);
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
function fileExists(dir, pattern) {
|
||||
try {
|
||||
const r = execSync(`find ${dir} -path "*${pattern}*" -type f 2>/dev/null | head -1`, { encoding: "utf8", cwd: WORKSPACE });
|
||||
return r.trim().length > 0;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Step 1: RequirementPackage (SF-01 + SF-02)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function loadJSON(path) {
|
||||
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return {}; }
|
||||
}
|
||||
|
||||
function generateRequirementPackage(domain) {
|
||||
console.log(`\n📋 [${domain.id}] Generating RequirementPackage...`);
|
||||
const t0 = Date.now();
|
||||
|
||||
// SF-01: PRD
|
||||
const prdOut = join(BENCH_ROOT, domain.id, "prd.json");
|
||||
mkdirSync(dirname(prdOut), { recursive: true });
|
||||
runAgent(
|
||||
join(SCRIPTS, "project-intake-agent.mjs"),
|
||||
`--input "${domain.input}" --output "${prdOut}"`
|
||||
);
|
||||
const prd = loadJSON(prdOut);
|
||||
|
||||
// SF-02: Architecture
|
||||
const archOut = join(BENCH_ROOT, domain.id, "arch.json");
|
||||
runAgent(
|
||||
join(SCRIPTS, "architecture-agent.mjs"),
|
||||
`--input "${prdOut}" --output "${archOut}"`
|
||||
);
|
||||
const arch = loadJSON(archOut);
|
||||
|
||||
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
console.log(` ✅ PRD + Architecture generated (${elapsed}s)`);
|
||||
console.log(` Project: ${prd.projectName || "?"}, Domain: ${prd.domain || "?"}`);
|
||||
console.log(` Features: ${prd.features?.length || 0}, Pages: ${prd.pages?.length || 0}, APIs: ${prd.apiRequirements?.length || 0}`);
|
||||
|
||||
return { prd, arch, elapsed: parseFloat(elapsed), prdPath: prdOut, archPath: archOut };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Step 2: Frontend Builder
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateFrontend(domain, prdPath, archPath) {
|
||||
console.log(` 🎨 Generating Frontend...`);
|
||||
const t0 = Date.now();
|
||||
const outDir = join(BENCH_ROOT, domain.id, "frontend");
|
||||
|
||||
const result = runAgent(
|
||||
join(SCRIPTS, "frontend-builder-agent.mjs"),
|
||||
`--prd "${prdPath}" --arch "${archPath}" --output "${outDir}"`
|
||||
);
|
||||
|
||||
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
const fileCount = countFiles(outDir);
|
||||
|
||||
// Validation checks
|
||||
const checks = {
|
||||
hasPackageJson: fileExists(outDir, "package.json"),
|
||||
hasNextConfig: fileExists(outDir, "next.config.*"),
|
||||
hasAppPage: fileExists(outDir, "app/page.*"),
|
||||
hasEntityPages: fileExists(outDir, "app/") && countFiles(join(outDir, "app")) > 2,
|
||||
hasServices: fileExists(outDir, "services/"),
|
||||
hasTypes: fileExists(outDir, "types/"),
|
||||
fileCount,
|
||||
};
|
||||
|
||||
const allPassed = checks.hasPackageJson && checks.hasNextConfig && checks.hasAppPage && checks.hasEntityPages;
|
||||
|
||||
console.log(` ${allPassed ? "✅" : "❌"} Frontend generated (${elapsed}s, ${fileCount} files)`);
|
||||
if (!allPassed) {
|
||||
console.log(` Missing: ${Object.entries(checks).filter(([,v]) => !v).map(([k]) => k).join(", ")}`);
|
||||
}
|
||||
|
||||
return { ...checks, elapsed: parseFloat(elapsed), allPassed, result };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Step 3: Backend Builder
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateBackend(domain, prdPath, archPath) {
|
||||
console.log(` ⚙️ Generating Backend...`);
|
||||
const t0 = Date.now();
|
||||
const outDir = join(BENCH_ROOT, domain.id, "backend");
|
||||
|
||||
const result = runAgent(
|
||||
join(SCRIPTS, "backend-builder-agent.mjs"),
|
||||
`--prd "${prdPath}" --arch "${archPath}" --output "${outDir}"`
|
||||
);
|
||||
|
||||
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
const fileCount = countFiles(outDir);
|
||||
|
||||
const checks = {
|
||||
hasPackageJson: fileExists(outDir, "package.json"),
|
||||
hasDbSchema: fileExists(outDir, "schema") || fileExists(outDir, "db"),
|
||||
hasRoutes: fileExists(outDir, "routes/"),
|
||||
hasServices: fileExists(outDir, "services/"),
|
||||
hasAuth: fileExists(outDir, "auth"),
|
||||
hasMiddleware: fileExists(outDir, "middleware"),
|
||||
hasTypes: fileExists(outDir, "types/"),
|
||||
fileCount,
|
||||
};
|
||||
|
||||
const allPassed = checks.hasPackageJson && checks.hasRoutes && checks.hasServices && checks.hasAuth;
|
||||
|
||||
console.log(` ${allPassed ? "✅" : "❌"} Backend generated (${elapsed}s, ${fileCount} files)`);
|
||||
if (!allPassed) {
|
||||
console.log(` Missing: ${Object.entries(checks).filter(([,v]) => !v).map(([k]) => k).join(", ")}`);
|
||||
}
|
||||
|
||||
return { ...checks, elapsed: parseFloat(elapsed), allPassed, result };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Step 4: Fullstack Composer
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateFullstack(domain, prdPath, archPath) {
|
||||
console.log(` 🔗 Composing Fullstack...`);
|
||||
const t0 = Date.now();
|
||||
const outDir = join(BENCH_ROOT, domain.id, "fullstack");
|
||||
|
||||
const result = runAgent(
|
||||
join(SCRIPTS, "fullstack-composer-agent.mjs"),
|
||||
`--prd "${prdPath}" --arch "${archPath}" --output "${outDir}"`
|
||||
);
|
||||
|
||||
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
const fileCount = countFiles(outDir);
|
||||
|
||||
const checks = {
|
||||
hasWeb: fileExists(outDir, "apps/web/"),
|
||||
hasApi: fileExists(outDir, "apps/api/"),
|
||||
hasSharedTypes: fileExists(outDir, "packages/shared-types/"),
|
||||
hasSharedConfig: fileExists(outDir, "packages/shared-config/"),
|
||||
hasRootPackageJson: fileExists(outDir, "package.json"),
|
||||
hasReadme: fileExists(outDir, "README.md"),
|
||||
fileCount,
|
||||
};
|
||||
|
||||
const allPassed = checks.hasWeb && checks.hasApi && checks.hasSharedTypes && checks.hasRootPackageJson;
|
||||
|
||||
console.log(` ${allPassed ? "✅" : "❌"} Fullstack composed (${elapsed}s, ${fileCount} files)`);
|
||||
if (!allPassed) {
|
||||
console.log(` Missing: ${Object.entries(checks).filter(([,v]) => !v).map(([k]) => k).join(", ")}`);
|
||||
}
|
||||
|
||||
return { ...checks, elapsed: parseFloat(elapsed), allPassed, result };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Step 5: Build Validation
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function validateBuild(domain) {
|
||||
console.log(` 🔨 Build Validation...`);
|
||||
|
||||
const results = { frontend: null, backend: null, fullstack: null };
|
||||
|
||||
// Frontend build
|
||||
const feDir = join(BENCH_ROOT, domain.id, "frontend");
|
||||
if (existsSync(join(feDir, "package.json"))) {
|
||||
console.log(` Frontend: npm install...`);
|
||||
const install = cmd("npm install --legacy-peer-deps 2>&1", { cwd: feDir, timeout: 120_000 });
|
||||
if (install.error) {
|
||||
results.frontend = { build: "FAIL", reason: `npm install failed: ${install.stderr?.slice(0, 200) || install.error}` };
|
||||
console.log(` ❌ npm install failed`);
|
||||
} else {
|
||||
console.log(` Frontend: npm run build...`);
|
||||
// Next.js build may need specific config; try build, if no build script, try next build
|
||||
const buildResult = cmd("npm run build 2>&1 || npx next build 2>&1", { cwd: feDir, timeout: 180_000 });
|
||||
const buildFailed = buildResult.error || (typeof buildResult === 'string' && buildResult.includes('error'));
|
||||
const buildErrText = typeof buildResult === 'string' ? buildResult.split('\n').filter(l => l.includes('error') || l.includes('Error') || l.includes('⨯')).slice(0,5).join(' || ') : (buildResult.stderr?.slice(0, 500) || buildResult.error);
|
||||
results.frontend = {
|
||||
build: buildFailed ? "FAIL" : "PASS",
|
||||
error: buildFailed ? buildErrText : null
|
||||
};
|
||||
console.log(` ${buildFailed ? "❌" : "✅"} Frontend build ${results.frontend.build}`);
|
||||
}
|
||||
} else {
|
||||
results.frontend = { build: "SKIP", reason: "No package.json" };
|
||||
}
|
||||
|
||||
// Backend build
|
||||
const beDir = join(BENCH_ROOT, domain.id, "backend");
|
||||
if (existsSync(join(beDir, "package.json"))) {
|
||||
console.log(` Backend: npm install...`);
|
||||
const install = cmd("npm install --legacy-peer-deps 2>&1", { cwd: beDir, timeout: 120_000 });
|
||||
if (install.error) {
|
||||
results.backend = { build: "FAIL", reason: `npm install failed: ${install.stderr?.slice(0, 200) || install.error}` };
|
||||
console.log(` ❌ npm install failed`);
|
||||
} else {
|
||||
console.log(` Backend: tsc --noEmit...`);
|
||||
const buildResult = cmd("npx tsc --noEmit 2>&1", { cwd: beDir, timeout: 120_000 });
|
||||
const buildFailed = buildResult.error || (typeof buildResult === 'string' && buildResult.includes('error TS'));
|
||||
const buildErrText = typeof buildResult === 'string' ? buildResult.split('\n').filter(l => l.includes('error TS')).slice(0,5).join(' || ') : (buildResult.stderr?.slice(0, 500) || buildResult.error);
|
||||
results.backend = {
|
||||
build: buildFailed ? "FAIL" : "PASS",
|
||||
error: buildFailed ? buildErrText : null
|
||||
};
|
||||
console.log(` ${buildFailed ? "❌" : "✅"} Backend typecheck ${results.backend.build}`);
|
||||
}
|
||||
} else {
|
||||
results.backend = { build: "SKIP", reason: "No package.json" };
|
||||
}
|
||||
|
||||
// Fullstack build (just verify structure, don't run full monorepo build)
|
||||
const fsDir = join(BENCH_ROOT, domain.id, "fullstack");
|
||||
if (existsSync(join(fsDir, "package.json"))) {
|
||||
results.fullstack = { build: "PASS", reason: "Structure validated (monorepo)" };
|
||||
console.log(` ✅ Fullstack structure validated`);
|
||||
} else {
|
||||
results.fullstack = { build: "FAIL", reason: "No root package.json" };
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Step 6: API Validation
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function validateAPI(domain) {
|
||||
console.log(` 🧪 API Validation...`);
|
||||
|
||||
const beDir = join(BENCH_ROOT, domain.id, "backend");
|
||||
const results = {};
|
||||
|
||||
if (!existsSync(join(beDir, "package.json"))) {
|
||||
return { error: "No backend package.json" };
|
||||
}
|
||||
|
||||
// Check route files for expected endpoints
|
||||
const routesDir = join(beDir, "src", "routes");
|
||||
const routeFiles = [];
|
||||
try {
|
||||
const find = execSync(`find ${routesDir} -name "*.ts" -type f 2>/dev/null`, { encoding: "utf8", cwd: WORKSPACE });
|
||||
routeFiles.push(...find.trim().split("\n").filter(Boolean));
|
||||
} catch {}
|
||||
|
||||
// Check for auth routes
|
||||
const hasAuthRoute = routeFiles.some(f => f.includes("auth"));
|
||||
const hasCRUDRoutes = routeFiles.filter(f => !f.includes("auth")).length;
|
||||
|
||||
// Check for health endpoint
|
||||
const indexFile = join(beDir, "src", "index.ts");
|
||||
let hasHealth = false;
|
||||
try {
|
||||
hasHealth = readFileSync(indexFile, "utf8").includes("health");
|
||||
} catch {}
|
||||
|
||||
// Check CRUD route content
|
||||
const crudOps = { create: false, read: false, update: false, delete: false };
|
||||
for (const f of routeFiles) {
|
||||
try {
|
||||
const content = readFileSync(f, "utf8");
|
||||
if (content.includes(".post(") || content.includes("app.post") || content.includes("router.post")) crudOps.create = true;
|
||||
if (content.includes(".get(") || content.includes("app.get") || content.includes("router.get")) crudOps.read = true;
|
||||
if (content.includes(".put(") || content.includes("app.put") || content.includes("router.put") || content.includes(".patch(") || content.includes("app.patch") || content.includes("router.patch")) crudOps.update = true;
|
||||
if (content.includes(".delete(") || content.includes("app.delete") || content.includes("router.delete")) crudOps.delete = true;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Check auth route content
|
||||
let authOps = { register: false, login: false, me: false };
|
||||
const authFile = routeFiles.find(f => f.includes("auth"));
|
||||
if (authFile) {
|
||||
try {
|
||||
const content = readFileSync(authFile, "utf8");
|
||||
authOps.register = content.includes("register") || content.includes("Register");
|
||||
authOps.login = content.includes("login") || content.includes("Login");
|
||||
authOps.me = content.includes("/me") || content.includes("'me'") || content.includes('"me"');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const crudPass = Object.values(crudOps).every(Boolean);
|
||||
const authPass = authOps.register && authOps.login;
|
||||
|
||||
results.health = hasHealth;
|
||||
results.register = authOps.register;
|
||||
results.login = authOps.login;
|
||||
results.me = authOps.me;
|
||||
results.create = crudOps.create;
|
||||
results.read = crudOps.read;
|
||||
results.update = crudOps.update;
|
||||
results.delete = crudOps.delete;
|
||||
results.crudAllPass = crudPass;
|
||||
results.authAllPass = authPass;
|
||||
results.routeCount = routeFiles.length;
|
||||
|
||||
console.log(` Health: ${hasHealth ? "✅" : "❌"} | Auth: ${authPass ? "✅" : "❌"} | CRUD: ${crudPass ? "✅" : "❌"} | Routes: ${routeFiles.length}`);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Step 7: Report Generation
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateReport(allResults) {
|
||||
console.log("\n\n📊 Generating Benchmark Report...\n");
|
||||
|
||||
const lines = [];
|
||||
const h = (text) => { lines.push(text); return lines; };
|
||||
|
||||
h(`# Domain Benchmark Report`);
|
||||
h(``);
|
||||
h(`> Generated: ${new Date().toISOString()}`);
|
||||
h(`> System: project-intake-agent → architecture-agent → frontend-builder → backend-builder → fullstack-composer`);
|
||||
h(``);
|
||||
|
||||
// Summary metrics
|
||||
const total = allResults.length;
|
||||
const fePass = allResults.filter(r => r.frontend?.allPassed).length;
|
||||
const bePass = allResults.filter(r => r.backend?.allPassed).length;
|
||||
const fsPass = allResults.filter(r => r.fullstack?.allPassed).length;
|
||||
const buildPass = allResults.filter(r => r.build?.frontend?.build === "PASS").length;
|
||||
const typecheckPass = allResults.filter(r => r.build?.backend?.build === "PASS").length;
|
||||
const crudPass = allResults.filter(r => r.api?.crudAllPass).length;
|
||||
const authPass = allResults.filter(r => r.api?.authAllPass).length;
|
||||
|
||||
h(`## Summary`);
|
||||
h(``);
|
||||
h(`| Metric | Value |`);
|
||||
h(`|--------|-------|`);
|
||||
h(`| Total Domains | ${total} |`);
|
||||
h(`| Frontend Generated | ${fePass}/${total} |`);
|
||||
h(`| Backend Generated | ${bePass}/${total} |`);
|
||||
h(`| Fullstack Composed | ${fsPass}/${total} |`);
|
||||
h(`| Frontend Build PASS | ${buildPass}/${total} |`);
|
||||
h(`| Backend Typecheck PASS | ${typecheckPass}/${total} |`);
|
||||
h(`| CRUD PASS | ${crudPass}/${total} |`);
|
||||
h(`| Auth PASS | ${authPass}/${total} |`);
|
||||
h(``);
|
||||
|
||||
const avgFrontendFiles = allResults.reduce((s, r) => s + (r.frontend?.fileCount || 0), 0) / total;
|
||||
const avgBackendFiles = allResults.reduce((s, r) => s + (r.backend?.fileCount || 0), 0) / total;
|
||||
const avgFullstackFiles = allResults.reduce((s, r) => s + (r.fullstack?.fileCount || 0), 0) / total;
|
||||
const avgReqTime = allResults.reduce((s, r) => s + (r.reqTime || 0), 0) / total;
|
||||
const avgFeTime = allResults.reduce((s, r) => s + (r.frontend?.elapsed || 0), 0) / total;
|
||||
const avgBeTime = allResults.reduce((s, r) => s + (r.backend?.elapsed || 0), 0) / total;
|
||||
const avgFsTime = allResults.reduce((s, r) => s + (r.fullstack?.elapsed || 0), 0) / total;
|
||||
const avgTotalTime = allResults.reduce((s, r) => s + (r.totalTime || 0), 0) / total;
|
||||
|
||||
h(`## Timing`);
|
||||
h(``);
|
||||
h(`| Phase | Avg Time |`);
|
||||
h(`|-------|----------|`);
|
||||
h(`| RequirementPackage | ${avgReqTime.toFixed(1)}s |`);
|
||||
h(`| Frontend Builder | ${avgFeTime.toFixed(1)}s |`);
|
||||
h(`| Backend Builder | ${avgBeTime.toFixed(1)}s |`);
|
||||
h(`| Fullstack Composer | ${avgFsTime.toFixed(1)}s |`);
|
||||
h(`| **Total Pipeline** | **${avgTotalTime.toFixed(1)}s** |`);
|
||||
h(``);
|
||||
|
||||
h(`## Files Generated`);
|
||||
h(``);
|
||||
h(`| Layer | Avg Files |`);
|
||||
h(`|-------|-----------|`);
|
||||
h(`| Frontend | ${avgFrontendFiles.toFixed(0)} |`);
|
||||
h(`| Backend | ${avgBackendFiles.toFixed(0)} |`);
|
||||
h(`| Fullstack | ${avgFullstackFiles.toFixed(0)} |`);
|
||||
h(``);
|
||||
|
||||
// Domain Results Table
|
||||
h(`## Domain Results`);
|
||||
h(``);
|
||||
h(`| Domain | FE Gen | BE Gen | FS Gen | FE Build | BE Typecheck | CRUD | Auth | Total Files | Time |`);
|
||||
h(`|--------|--------|--------|--------|----------|-------------|------|------|-------------|------|`);
|
||||
|
||||
for (const r of allResults) {
|
||||
const feOk = r.frontend?.allPassed ? "✅" : "❌";
|
||||
const beOk = r.backend?.allPassed ? "✅" : "❌";
|
||||
const fsOk = r.fullstack?.allPassed ? "✅" : "❌";
|
||||
const feBuild = r.build?.frontend?.build === "PASS" ? "✅" : r.build?.frontend?.build === "SKIP" ? "—" : "❌";
|
||||
const beTC = r.build?.backend?.build === "PASS" ? "✅" : r.build?.backend?.build === "SKIP" ? "—" : "❌";
|
||||
const crudOk = r.api?.crudAllPass ? "✅" : "❌";
|
||||
const authOk = r.api?.authAllPass ? "✅" : "❌";
|
||||
const totalFiles = (r.frontend?.fileCount || 0) + (r.backend?.fileCount || 0) + (r.fullstack?.fileCount || 0);
|
||||
h(`| ${r.id} | ${feOk} | ${beOk} | ${fsOk} | ${feBuild} | ${beTC} | ${crudOk} | ${authOk} | ${totalFiles} | ${(r.totalTime || 0).toFixed(1)}s |`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
// Detailed Domain Results
|
||||
h(`## Detailed Results`);
|
||||
h(``);
|
||||
|
||||
for (const r of allResults) {
|
||||
h(`### ${r.id}`);
|
||||
h(``);
|
||||
h(`**Input:** "${r.input}"`);
|
||||
h(`**Project:** ${r.projectName || "N/A"} | **Domain:** ${r.domain || "N/A"}`);
|
||||
h(``);
|
||||
|
||||
h(`#### Frontend`);
|
||||
if (r.frontend?.allPassed === false) {
|
||||
const missing = Object.entries(r.frontend || {})
|
||||
.filter(([k, v]) => k.startsWith("has") && !v)
|
||||
.map(([k]) => k.replace("has", ""));
|
||||
h(`❌ Missing: ${missing.join(", ")}`);
|
||||
} else {
|
||||
h(`✅ ${r.frontend?.fileCount || 0} files generated`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
h(`#### Backend`);
|
||||
if (r.backend?.allPassed === false) {
|
||||
const missing = Object.entries(r.backend || {})
|
||||
.filter(([k, v]) => k.startsWith("has") && !v)
|
||||
.map(([k]) => k.replace("has", ""));
|
||||
h(`❌ Missing: ${missing.join(", ")}`);
|
||||
} else {
|
||||
h(`✅ ${r.backend?.fileCount || 0} files generated`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
h(`#### Fullstack`);
|
||||
if (r.fullstack?.allPassed === false) {
|
||||
const missing = Object.entries(r.fullstack || {})
|
||||
.filter(([k, v]) => k.startsWith("has") && !v)
|
||||
.map(([k]) => k.replace("has", ""));
|
||||
h(`❌ Missing: ${missing.join(", ")}`);
|
||||
} else {
|
||||
h(`✅ ${r.fullstack?.fileCount || 0} files generated`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
h(`#### Build`);
|
||||
if (r.build?.frontend?.build === "FAIL") {
|
||||
h(`❌ Frontend build failed: ${r.build.frontend.reason || r.build.frontend.error || ""}`);
|
||||
} else {
|
||||
h(`✅ Frontend build: ${r.build?.frontend?.build || "SKIP"}`);
|
||||
}
|
||||
if (r.build?.backend?.build === "FAIL") {
|
||||
h(`❌ Backend typecheck failed: ${r.build.backend.reason || r.build.backend.error || ""}`);
|
||||
} else {
|
||||
h(`✅ Backend typecheck: ${r.build?.backend?.build || "SKIP"}`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
h(`#### API`);
|
||||
if (r.api) {
|
||||
h(`| Endpoint | Status |`);
|
||||
h(`|----------|--------|`);
|
||||
h(`| Health | ${r.api.health ? "✅" : "❌"} |`);
|
||||
h(`| Register | ${r.api.register ? "✅" : "❌"} |`);
|
||||
h(`| Login | ${r.api.login ? "✅" : "❌"} |`);
|
||||
h(`| Me | ${r.api.me ? "✅" : "❌"} |`);
|
||||
h(`| Create | ${r.api.create ? "✅" : "❌"} |`);
|
||||
h(`| Read | ${r.api.read ? "✅" : "❌"} |`);
|
||||
h(`| Update | ${r.api.update ? "✅" : "❌"} |`);
|
||||
h(`| Delete | ${r.api.delete ? "✅" : "❌"} |`);
|
||||
}
|
||||
h(``);
|
||||
}
|
||||
|
||||
// Failure Analysis
|
||||
h(`## Failure Analysis`);
|
||||
h(``);
|
||||
|
||||
const failures = allResults.filter(r =>
|
||||
!r.frontend?.allPassed || !r.backend?.allPassed || !r.fullstack?.allPassed ||
|
||||
r.build?.frontend?.build === "FAIL" || r.build?.backend?.build === "FAIL" ||
|
||||
!r.api?.crudAllPass || !r.api?.authAllPass
|
||||
);
|
||||
|
||||
if (failures.length === 0) {
|
||||
h(`✅ **All domains passed all checks!**`);
|
||||
h(``);
|
||||
} else {
|
||||
h(`### Failure Categories`);
|
||||
h(``);
|
||||
|
||||
// Categorize failures
|
||||
const feGenFails = allResults.filter(r => !r.frontend?.allPassed);
|
||||
const beGenFails = allResults.filter(r => !r.backend?.allPassed);
|
||||
const fsGenFails = allResults.filter(r => !r.fullstack?.allPassed);
|
||||
const feBuildFails = allResults.filter(r => r.build?.frontend?.build === "FAIL");
|
||||
const beBuildFails = allResults.filter(r => r.build?.backend?.build === "FAIL");
|
||||
const crudFails = allResults.filter(r => !r.api?.crudAllPass);
|
||||
const authFails = allResults.filter(r => !r.api?.authAllPass);
|
||||
|
||||
h(`| Category | Count | Domains |`);
|
||||
h(`|----------|-------|---------|`);
|
||||
h(`| Frontend Generation | ${feGenFails.length} | ${feGenFails.map(r => r.id).join(", ") || "—"} |`);
|
||||
h(`| Backend Generation | ${beGenFails.length} | ${beGenFails.map(r => r.id).join(", ") || "—"} |`);
|
||||
h(`| Fullstack Composition | ${fsGenFails.length} | ${fsGenFails.map(r => r.id).join(", ") || "—"} |`);
|
||||
h(`| Frontend Build | ${feBuildFails.length} | ${feBuildFails.map(r => r.id).join(", ") || "—"} |`);
|
||||
h(`| Backend Typecheck | ${beBuildFails.length} | ${beBuildFails.map(r => r.id).join(", ") || "—"} |`);
|
||||
h(`| CRUD Missing | ${crudFails.length} | ${crudFails.map(r => r.id).join(", ") || "—"} |`);
|
||||
h(`| Auth Missing | ${authFails.length} | ${authFails.map(r => r.id).join(", ") || "—"} |`);
|
||||
h(``);
|
||||
|
||||
// Detailed failure reasons
|
||||
h(`### Failure Details`);
|
||||
h(``);
|
||||
for (const r of allResults) {
|
||||
const issues = [];
|
||||
if (!r.frontend?.allPassed) {
|
||||
const missing = Object.entries(r.frontend || {}).filter(([k, v]) => k.startsWith("has") && !v).map(([k]) => k.replace("has", ""));
|
||||
issues.push(`Frontend: missing ${missing.join(", ")}`);
|
||||
}
|
||||
if (!r.backend?.allPassed) {
|
||||
const missing = Object.entries(r.backend || {}).filter(([k, v]) => k.startsWith("has") && !v).map(([k]) => k.replace("has", ""));
|
||||
issues.push(`Backend: missing ${missing.join(", ")}`);
|
||||
}
|
||||
if (!r.fullstack?.allPassed) {
|
||||
const missing = Object.entries(r.fullstack || {}).filter(([k, v]) => k.startsWith("has") && !v).map(([k]) => k.replace("has", ""));
|
||||
issues.push(`Fullstack: missing ${missing.join(", ")}`);
|
||||
}
|
||||
if (r.build?.frontend?.build === "FAIL") issues.push(`Frontend build: ${r.build.frontend.error || r.build.frontend.reason}`);
|
||||
if (r.build?.backend?.build === "FAIL") issues.push(`Backend typecheck: ${r.build.backend.error || r.build.backend.reason}`);
|
||||
if (!r.api?.crudAllPass) {
|
||||
const missing = Object.entries({create: r.api?.create, read: r.api?.read, update: r.api?.update, delete: r.api?.delete})
|
||||
.filter(([,v]) => !v).map(([k]) => k);
|
||||
issues.push(`CRUD: missing ${missing.join(", ")}`);
|
||||
}
|
||||
if (!r.api?.authAllPass) {
|
||||
const missing = Object.entries({register: r.api?.register, login: r.api?.login, me: r.api?.me})
|
||||
.filter(([,v]) => !v).map(([k]) => k);
|
||||
issues.push(`Auth: missing ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
h(`**${r.id}:**`);
|
||||
for (const i of issues) h(`- ${i}`);
|
||||
h(``);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern Analysis
|
||||
h(`## Pattern Analysis`);
|
||||
h(``);
|
||||
|
||||
// Best domains
|
||||
const scored = allResults.map(r => {
|
||||
let score = 0;
|
||||
if (r.frontend?.allPassed) score += 2;
|
||||
if (r.backend?.allPassed) score += 2;
|
||||
if (r.fullstack?.allPassed) score += 1;
|
||||
if (r.build?.frontend?.build === "PASS") score += 2;
|
||||
if (r.build?.backend?.build === "PASS") score += 2;
|
||||
if (r.api?.crudAllPass) score += 2;
|
||||
if (r.api?.authAllPass) score += 2;
|
||||
return { ...r, score };
|
||||
}).sort((a, b) => b.score - a.score);
|
||||
|
||||
h(`### Best Performing Domains`);
|
||||
h(``);
|
||||
h(`| Domain | Score | Key Strength |`);
|
||||
h(`|--------|-------|-------------|`);
|
||||
for (const r of scored.slice(0, 3)) {
|
||||
h(`| ${r.id} | ${r.score}/13 | ${r.domain || "N/A"} |`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
h(`### Weakest Domains`);
|
||||
h(``);
|
||||
h(`| Domain | Score | Key Issue |`);
|
||||
h(`|--------|-------|-----------|`);
|
||||
for (const r of scored.slice(-3).reverse()) {
|
||||
const issues = [];
|
||||
if (!r.frontend?.allPassed) issues.push("FE gen");
|
||||
if (!r.backend?.allPassed) issues.push("BE gen");
|
||||
if (r.build?.frontend?.build === "FAIL") issues.push("FE build");
|
||||
if (r.build?.backend?.build === "FAIL") issues.push("BE typecheck");
|
||||
if (!r.api?.crudAllPass) issues.push("CRUD");
|
||||
if (!r.api?.authAllPass) issues.push("Auth");
|
||||
h(`| ${r.id} | ${r.score}/13 | ${issues.join(", ") || "—"} |`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
h(`### Domain Type Analysis`);
|
||||
h(``);
|
||||
h(`| Domain Type | Count | Avg Score |`);
|
||||
h(`|-------------|-------|-----------|`);
|
||||
const byDomain = {};
|
||||
for (const r of allResults) {
|
||||
const d = r.domain || "unknown";
|
||||
if (!byDomain[d]) byDomain[d] = { count: 0, scores: [] };
|
||||
byDomain[d].count++;
|
||||
byDomain[d].scores.push(r.score);
|
||||
}
|
||||
for (const [domain, data] of Object.entries(byDomain)) {
|
||||
const avg = (data.scores.reduce((a,b) => a + b, 0) / data.scores.length).toFixed(1);
|
||||
h(`| ${domain} | ${data.count} | ${avg} |`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
// Failure categories (defined above already, but recompute for Fixes section)
|
||||
const feGenFailsR = allResults.filter(r => !r.frontend?.allPassed);
|
||||
const beGenFailsR = allResults.filter(r => !r.backend?.allPassed);
|
||||
const fsGenFailsR = allResults.filter(r => !r.fullstack?.allPassed);
|
||||
const feBuildFailsR = allResults.filter(r => r.build?.frontend?.build === "FAIL");
|
||||
const beBuildFailsR = allResults.filter(r => r.build?.backend?.build === "FAIL");
|
||||
const crudFailsR = allResults.filter(r => !r.api?.crudAllPass);
|
||||
const authFailsR = allResults.filter(r => !r.api?.authAllPass);
|
||||
|
||||
// Required Fixes
|
||||
h(`## Required Fixes`);
|
||||
h(``);
|
||||
|
||||
const criticalFixes = [];
|
||||
const highFixes = [];
|
||||
const mediumFixes = [];
|
||||
|
||||
// Analyze patterns
|
||||
if (feBuildFailsR.length > 0) {
|
||||
criticalFixes.push(`**Frontend Build Failures** (${feBuildFailsR.length}/${total}): Fix Next.js build issues in frontend-builder-agent — check tsconfig, module resolution, component imports`);
|
||||
}
|
||||
if (beBuildFailsR.length > 0) {
|
||||
criticalFixes.push(`**Backend Typecheck Failures** (${beBuildFailsR.length}/${total}): Fix TypeScript errors in backend-builder-agent — check type definitions, import paths`);
|
||||
}
|
||||
if (beGenFailsR.length > 0) {
|
||||
highFixes.push(`**Backend Generation Gaps** (${beGenFailsR.length}/${total}): Missing routes/services/auth in some domains`);
|
||||
}
|
||||
if (crudFailsR.length > 0) {
|
||||
highFixes.push(`**CRUD Completeness** (${crudFailsR.length}/${total}): Some domains missing full CRUD operations`);
|
||||
}
|
||||
if (authFailsR.length > 0) {
|
||||
highFixes.push(`**Auth Endpoint Coverage** (${authFailsR.length}/${total}): Missing register/login/me in some domains`);
|
||||
}
|
||||
if (feGenFailsR.length > 0) {
|
||||
mediumFixes.push(`**Frontend Generation Gaps** (${feGenFailsR.length}/${total}): Missing pages or services in some domains`);
|
||||
}
|
||||
|
||||
h(`### P0 — Critical`);
|
||||
h(``);
|
||||
if (criticalFixes.length === 0) {
|
||||
h(`✅ No critical issues found.`);
|
||||
} else {
|
||||
for (const f of criticalFixes) h(`1. ${f}`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
h(`### P1 — High`);
|
||||
h(``);
|
||||
if (highFixes.length === 0) {
|
||||
h(`✅ No high-priority issues found.`);
|
||||
} else {
|
||||
for (let i = 0; i < highFixes.length; i++) h(`${i + 1}. ${highFixes[i]}`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
h(`### P2 — Medium`);
|
||||
h(``);
|
||||
if (mediumFixes.length === 0) {
|
||||
h(`✅ No medium-priority issues found.`);
|
||||
} else {
|
||||
for (let i = 0; i < mediumFixes.length; i++) h(`${i + 1}. ${mediumFixes[i]}`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
// Conclusion
|
||||
h(`## Conclusion`);
|
||||
h(``);
|
||||
const passRate = allResults.filter(r => r.score >= 11).length;
|
||||
if (passRate === total) {
|
||||
h(`🎉 **All ${total} domains passed!** The system demonstrates robust generalization capability across diverse business domains.`);
|
||||
h(``);
|
||||
h(`The RequirementPackage → Frontend Builder → Backend Builder → Fullstack Composer pipeline is production-ready for Web Fullstack generation.`);
|
||||
h(``);
|
||||
h(`**Next: Electron Builder can now proceed.**`);
|
||||
} else {
|
||||
h(`⚠️ **${passRate}/${total} domains fully passed.**`);
|
||||
h(``);
|
||||
h(`The pipeline shows partial generalization. ${total - passRate} domains need fixes before the system can be considered a truly general Web Fullstack Generator.`);
|
||||
h(``);
|
||||
h(`**Electron Builder should wait until fixes above are addressed.**`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
h(`---`);
|
||||
h(`*Report generated by Domain Benchmark Suite*`);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Main
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const filterDomain = args[0] === "--domain" ? args[1] : null;
|
||||
const skipBuild = args.includes("--skip-build");
|
||||
const skipAPI = args.includes("--skip-api");
|
||||
|
||||
// Filter domains if specified
|
||||
const domains = filterDomain
|
||||
? DOMAINS.filter(d => d.id === filterDomain)
|
||||
: DOMAINS;
|
||||
|
||||
if (domains.length === 0) {
|
||||
console.error(`Unknown domain: ${filterDomain}`);
|
||||
console.error(`Available: ${DOMAINS.map(d => d.id).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🧪 Domain Benchmark Suite`);
|
||||
console.log(` Testing ${domains.length} domain(s)`);
|
||||
console.log(` Pipeline: project-intake → architecture → frontend → backend → fullstack`);
|
||||
if (skipBuild) console.log(` ⏭️ Skipping build validation`);
|
||||
if (skipAPI) console.log(` ⏭️ Skipping API validation`);
|
||||
console.log(``);
|
||||
|
||||
const allResults = [];
|
||||
|
||||
for (const domain of domains) {
|
||||
console.log(`\n${"=".repeat(60)}`);
|
||||
console.log(`🏗️ Domain: ${domain.id}`);
|
||||
console.log(`${"=".repeat(60)}`);
|
||||
|
||||
const tTotal = Date.now();
|
||||
|
||||
// Step 1: RequirementPackage
|
||||
const { prd, arch, elapsed: reqTime, prdPath, archPath } = generateRequirementPackage(domain);
|
||||
|
||||
// Step 2: Frontend
|
||||
const frontend = generateFrontend(domain, prdPath, archPath);
|
||||
|
||||
// Step 3: Backend
|
||||
const backend = generateBackend(domain, prdPath, archPath);
|
||||
|
||||
// Step 4: Fullstack
|
||||
const fullstack = generateFullstack(domain, prdPath, archPath);
|
||||
|
||||
// Step 5: Build (optional)
|
||||
let build = null;
|
||||
if (!skipBuild) {
|
||||
build = validateBuild(domain);
|
||||
}
|
||||
|
||||
// Step 6: API (optional)
|
||||
let api = null;
|
||||
if (!skipAPI) {
|
||||
api = validateAPI(domain);
|
||||
}
|
||||
|
||||
const totalTime = ((Date.now() - tTotal) / 1000).toFixed(1);
|
||||
|
||||
allResults.push({
|
||||
id: domain.id,
|
||||
input: domain.input,
|
||||
projectName: prd.projectName,
|
||||
domain: prd.domain,
|
||||
reqTime,
|
||||
frontend,
|
||||
backend,
|
||||
fullstack,
|
||||
build,
|
||||
api,
|
||||
totalTime: parseFloat(totalTime),
|
||||
});
|
||||
|
||||
console.log(`\n ⏱️ Total: ${totalTime}s`);
|
||||
}
|
||||
|
||||
// Generate report
|
||||
const report = generateReport(allResults);
|
||||
writeFileSync(REPORT_PATH, report, "utf8");
|
||||
console.log(`\n📄 Report written to: ${REPORT_PATH}`);
|
||||
|
||||
// Print summary
|
||||
const passCount = allResults.filter(r => {
|
||||
return r.frontend?.allPassed && r.backend?.allPassed && r.fullstack?.allPassed;
|
||||
}).length;
|
||||
|
||||
console.log(`\n📊 Summary: ${passCount}/${domains.length} domains generated successfully`);
|
||||
|
||||
// Also output JSON for programmatic consumption
|
||||
const jsonPath = resolve(WORKSPACE, "benchmark-results.json");
|
||||
writeFileSync(jsonPath, JSON.stringify(allResults, null, 2), "utf8");
|
||||
console.log(`📄 JSON results: ${jsonPath}`);
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error("Benchmark failed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+349
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env bash
|
||||
# dream-cycle.sh — 记忆 consolidation 周期(Dream Cycle)
|
||||
#
|
||||
# 灵感: openclaw-auto-dream 的 4 Phase Dream Cycle
|
||||
# v2 — 加入远程记忆服务器同步
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/dream-cycle.sh # 完整运行
|
||||
# bash scripts/dream-cycle.sh collect # Phase 1: 扫描 daily/
|
||||
# bash scripts/dream-cycle.sh sync-remote # 推送新条目到远程服务器
|
||||
# bash scripts/dream-cycle.sh evaluate # Phase 3: 健康评分
|
||||
# bash scripts/dream-cycle.sh health # 只看健康分
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
MEMORY_DIR="$WORKSPACE/memory"
|
||||
DAILY_DIR="$MEMORY_DIR/daily"
|
||||
REGISTERS_DIR="$MEMORY_DIR/registers"
|
||||
PROJECTS_DIR="$MEMORY_DIR/projects"
|
||||
MARKER="$MEMORY_DIR/.dream-cycle-last-run"
|
||||
NOW=$(date +%Y-%m-%d)
|
||||
NOW_EPOCH=$(date +%s)
|
||||
|
||||
# 远程记忆服务器
|
||||
MEMORY_SERVER="http://111.229.145.18"
|
||||
|
||||
# 上次运行时间
|
||||
LAST_RUN=""
|
||||
if [ -f "$MARKER" ]; then
|
||||
LAST_RUN=$(cat "$MARKER")
|
||||
fi
|
||||
|
||||
# ─── 智能标记检测 ──────────────────────────────────────
|
||||
|
||||
# 自动检测重要性(返回类型或空)
|
||||
detect_importance() {
|
||||
local line="$1"
|
||||
|
||||
# 决策句式
|
||||
if echo "$line" | grep -qE '(选择|决定|配置为|建立|部署|接入|升级|迁移|切换|采用)'; then
|
||||
echo "decision"
|
||||
return
|
||||
fi
|
||||
# 偏好表达
|
||||
if echo "$line" | grep -qE '(喜欢|习惯|总是|不要|偏好|改为|改成|以后都)'; then
|
||||
echo "preference"
|
||||
return
|
||||
fi
|
||||
# 纠正信号
|
||||
if echo "$line" | grep -qE '(不对|应该是|更正|其实是|原来是|发现.*错|误)'; then
|
||||
echo "correction"
|
||||
return
|
||||
fi
|
||||
# 重要事件
|
||||
if echo "$line" | grep -qE '(完成|成功|失败|上线|修复|解决|搞定|落地|生效)'; then
|
||||
echo "event"
|
||||
return
|
||||
fi
|
||||
# 工具/服务状态
|
||||
if echo "$line" | grep -qE '(状态|正常|异常|在线|离线|连接|断开)'; then
|
||||
echo "status"
|
||||
return
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ─── Phase 1: Collect — 扫描 daily/ ──────────────────────
|
||||
|
||||
collect() {
|
||||
echo "📡 Phase 1: Collect — 日志扫描(智能标记)"
|
||||
echo " 上次运行: ${LAST_RUN:-从未}"
|
||||
echo ""
|
||||
|
||||
local tempfile=$(mktemp)
|
||||
local count=0
|
||||
|
||||
for f in "$DAILY_DIR"/*.md; do
|
||||
[ -f "$f" ] || continue
|
||||
local fname=$(basename "$f")
|
||||
local fdate="${fname%.md}"
|
||||
|
||||
# 跳过旧文件
|
||||
if [ -n "$LAST_RUN" ] && [ "$fdate" != "$LAST_RUN" ] && [[ "$fdate" < "$LAST_RUN" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
while IFS= read -r line; do
|
||||
local trimmed=$(echo "$line" | sed 's/^\*\*//;s/\*\*$//')
|
||||
local type=""
|
||||
|
||||
# 1. 显式标记优先
|
||||
if echo "$trimmed" | grep -q '\[session-flush\]'; then
|
||||
type="session-summary"
|
||||
local ctx=$(grep -A 5 '\[session-flush\]' "$f" 2>/dev/null | grep '^-' | head -4 | tr '\n' ' ' | head -c 500 || true)
|
||||
[ -n "$ctx" ] && echo "$fdate|$type|$ctx" >> "$tempfile" && count=$((count+1)) && continue
|
||||
fi
|
||||
if echo "$trimmed" | grep -q '\[correction'; then
|
||||
type="correction"
|
||||
local ctx=$(echo "$trimmed" | head -c 300)
|
||||
[ -n "$ctx" ] && echo "$fdate|$type|$ctx" >> "$tempfile" && count=$((count+1)) && continue
|
||||
fi
|
||||
|
||||
# 2. 智能检测(只对非标题行、非空行)
|
||||
if [ -n "$trimmed" ] && ! echo "$trimmed" | grep -qE '^(#|\||-*$|>)'; then
|
||||
type=$(detect_importance "$trimmed")
|
||||
if [ -n "$type" ]; then
|
||||
local ctx=$(echo "$trimmed" | head -c 300)
|
||||
echo "$fdate|$type|$ctx" >> "$tempfile" && count=$((count+1))
|
||||
fi
|
||||
fi
|
||||
done < "$f"
|
||||
done
|
||||
|
||||
echo " 收集 $count 条待同步"
|
||||
echo "$tempfile"
|
||||
}
|
||||
|
||||
# ─── Phase 1.5: Sync Remote — 推送到记忆服务器 ────────────
|
||||
|
||||
sync_remote() {
|
||||
echo "📤 Phase 1.5: Sync Remote → $MEMORY_SERVER"
|
||||
echo ""
|
||||
|
||||
# 用 collect 扫出新条目
|
||||
local tmpfile=$(collect | tail -1)
|
||||
[ ! -f "$tmpfile" ] && echo " 无待同步条目" && return
|
||||
|
||||
local total=0 failed=0
|
||||
local tmpfile2=$(mktemp)
|
||||
|
||||
# 逐行解析发送
|
||||
while IFS='|' read -r date type content; do
|
||||
[ -z "$content" ] && continue
|
||||
local clean=$(echo "$content" | sed 's/^[- ]*//;s/"/\\"/g')
|
||||
[ -z "$clean" ] && continue
|
||||
|
||||
local resp=$(curl -s --max-time 5 -X POST "$MEMORY_SERVER/api/v2/add" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: ${MEMORY_API_KEY:-}" \
|
||||
-d "{\"content\":\"$clean\",\"type\":\"$type\",\"project\":\"xiaolong\",\"source\":\"dream-cycle\"}" 2>/dev/null || echo '{"error":"curl fail"}')
|
||||
|
||||
if echo "$resp" | grep -q 'stored'; then
|
||||
total=$((total+1))
|
||||
else
|
||||
failed=$((failed+1))
|
||||
fi
|
||||
done < "$tmpfile"
|
||||
|
||||
echo " 成功 $total / 失败 $failed"
|
||||
[ "$total" -gt 0 ] && echo " ✅ 记忆已写入远程服务器"
|
||||
echo "$NOW" > "$MARKER"
|
||||
rm -f "$tmpfile" "$tmpfile2"
|
||||
}
|
||||
|
||||
# ─── Phase 1.6: Sync Context Graph — 推送结构化记忆 ─────────
|
||||
|
||||
sync_context_graph() {
|
||||
echo "🧬 Phase 1.6: Sync ContextGraph → $MEMORY_SERVER"
|
||||
|
||||
if [ -f "$WORKSPACE/src/memory/sync-context-graph.js" ]; then
|
||||
node "$WORKSPACE/src/memory/sync-context-graph.js" 2>&1 | sed 's/^/ /'
|
||||
echo " ✅ ContextGraph 已同步"
|
||||
else
|
||||
echo " ⚠️ sync-context-graph.js 未找到,跳过"
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ─── Phase 2: Consolidate — 本地路由 ──────────────────────
|
||||
|
||||
consolidate() {
|
||||
echo "🔄 Phase 2: Consolidate — 本地路由"
|
||||
echo ""
|
||||
|
||||
mkdir -p "$REGISTERS_DIR"
|
||||
for reg in "$REGISTERS_DIR"/*.md; do
|
||||
[ -f "$reg" ] || continue
|
||||
local name=$(basename "$reg" .md)
|
||||
local cnt=$(grep -c '\- \[' "$reg" 2>/dev/null || echo 0)
|
||||
echo " registers/$name.md: ~${cnt} 条"
|
||||
done
|
||||
|
||||
local proj_cnt=$(ls "$PROJECTS_DIR"/*.md 2>/dev/null | wc -l | tr -d ' ')
|
||||
local vault_lines=$(wc -l < "$MEMORY_DIR/vault.md" 2>/dev/null || echo 0)
|
||||
local mem_lines=$(wc -l < "$WORKSPACE/MEMORY.md" 2>/dev/null || echo 0)
|
||||
|
||||
echo " projects/: ${proj_cnt} 个"
|
||||
echo " vault.md: ${vault_lines} 行"
|
||||
echo " MEMORY.md: ${mem_lines} 行"
|
||||
}
|
||||
|
||||
# ─── Phase 2.5: Refresh Core Memory — 动态刷新核心记忆块 ───
|
||||
|
||||
refresh_core_memory() {
|
||||
echo "🧠 Phase 2.5: Refresh Core Memory Blocks"
|
||||
echo ""
|
||||
|
||||
local core_dir="$MEMORY_DIR/core"
|
||||
[ ! -d "$core_dir" ] && echo " ⚠️ memory/core/ 不存在,跳过" && return
|
||||
|
||||
local now_iso=$(date -Iseconds)
|
||||
|
||||
# 1. active_projects.md — 从最近 3 天的 daily/ 提取项目动态
|
||||
local active_file="$core_dir/active_projects.md"
|
||||
if [ -f "$active_file" ]; then
|
||||
local recent_projects=$(grep -h '\*\*.*\*\*' "$DAILY_DIR"/*.md 2>/dev/null | grep -E '(项目|仓库|同步|接入|部署|升级|完成|进行中)' | tail -10 | sed 's/^/- /' || true)
|
||||
if [ -n "$recent_projects" ]; then
|
||||
local header=$(head -8 "$active_file")
|
||||
echo "$header" > "$active_file"
|
||||
echo "" >> "$active_file"
|
||||
echo "## 进行中" >> "$active_file"
|
||||
echo "" >> "$active_file"
|
||||
echo "$recent_projects" >> "$active_file"
|
||||
sed -i "" "s/updated_at:.*/updated_at: \"$now_iso\"/" "$active_file" 2>/dev/null || true
|
||||
echo " ✅ active_projects.md 已刷新"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. recent_decisions.md — 从最近 7 天的 vault.md 提取决策
|
||||
local decisions_file="$core_dir/recent_decisions.md"
|
||||
if [ -f "$decisions_file" ]; then
|
||||
local recent_decisions=$(grep -E '^- [0-9]{4}-[0-9]{2}-[0-9]{2}:' "$MEMORY_DIR/vault.md" 2>/dev/null | tail -10 || true)
|
||||
if [ -n "$recent_decisions" ]; then
|
||||
local header=$(head -8 "$decisions_file")
|
||||
echo "$header" > "$decisions_file"
|
||||
echo "" >> "$decisions_file"
|
||||
echo "# 最近决策" >> "$decisions_file"
|
||||
echo "" >> "$decisions_file"
|
||||
echo "$recent_decisions" >> "$decisions_file"
|
||||
sed -i "" "s/updated_at:.*/updated_at: \"$now_iso\"/" "$decisions_file" 2>/dev/null || true
|
||||
echo " ✅ recent_decisions.md 已刷新"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. tool_status.md — 只更新时间戳,内容由事件触发更新
|
||||
local tool_file="$core_dir/tool_status.md"
|
||||
if [ -f "$tool_file" ]; then
|
||||
sed -i "" "s/updated_at:.*/updated_at: \"$now_iso\"/" "$tool_file" 2>/dev/null || true
|
||||
echo " ✅ tool_status.md 时间戳已更新"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ─── Phase 3: Evaluate — 健康评分 ─────────────────────────
|
||||
|
||||
evaluate() {
|
||||
echo "📊 Phase 3: Evaluate — 健康评分"
|
||||
echo ""
|
||||
|
||||
local total_daily=$(ls "$DAILY_DIR"/*.md 2>/dev/null | wc -l | tr -d ' ')
|
||||
local reg_count=0 updated_regs=0
|
||||
|
||||
for f in "$REGISTERS_DIR"/*.md; do
|
||||
[ -f "$f" ] || continue
|
||||
reg_count=$((reg_count+1))
|
||||
local mtime=$(stat -f "%m" "$f" 2>/dev/null || echo 0)
|
||||
[ "$mtime" -gt 0 ] && [ $(( (NOW_EPOCH-mtime)/86400 )) -le 30 ] && updated_regs=$((updated_regs+1))
|
||||
done
|
||||
|
||||
local vault_lines=$(wc -l < "$MEMORY_DIR/vault.md" 2>/dev/null || echo 0)
|
||||
|
||||
# 各维度评分
|
||||
local freshness=100; [ "$total_daily" -gt 0 ] && freshness=$(( $(ls -t "$DAILY_DIR"/*.md 2>/dev/null | head -1 | xargs stat -f "%m" 2>/dev/null || echo 0) > $((NOW_EPOCH-86400*7)) ? 100 : 50 ))
|
||||
local coverage=100; [ "$reg_count" -gt 0 ] && coverage=$(( updated_regs*100/reg_count ))
|
||||
local efficiency=100; [ "$vault_lines" -gt 200 ] && efficiency=60
|
||||
local reliability=90
|
||||
local security=95
|
||||
local health=$(( (freshness*25+coverage*25+efficiency*20+reliability*15+security*15)/100 ))
|
||||
|
||||
echo " 📈 健康评分: ${health}/100"
|
||||
echo " Freshness: ${freshness}/100 | Coverage: ${coverage}/100 | Efficiency: ${efficiency}/100"
|
||||
echo " Reliability: ${reliability}/100 | Security: ${security}/100"
|
||||
echo " daily: ${total_daily}篇 | registers: ${reg_count}个 | vault: ${vault_lines}行"
|
||||
}
|
||||
|
||||
# ─── 入口 ───────────────────────────────────────────
|
||||
|
||||
case "${1:-all}" in
|
||||
collect) collect ;;
|
||||
sync-remote) sync_remote ;;
|
||||
sync-graph) sync_context_graph ;;
|
||||
evaluate) evaluate ;;
|
||||
health) evaluate 2>&1 | grep -E '健康|Freshness|daily' ;;
|
||||
all)
|
||||
echo "🌙 小龙的 Dream Cycle — $NOW $(date +%H:%M)"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
sync_remote
|
||||
sync_context_graph
|
||||
echo ""
|
||||
refresh_core_memory
|
||||
echo ""
|
||||
consolidate
|
||||
forget_context_graph
|
||||
echo ""
|
||||
evaluate
|
||||
echo ""
|
||||
echo "✅ Dream Cycle 完成 (下一轮: 每天 4:00)"
|
||||
;;
|
||||
core-refresh) refresh_core_memory ;;
|
||||
*) echo "用法: bash scripts/dream-cycle.sh [collect|sync-remote|evaluate|health]"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# ─── Phase 1.7: ContextGraph 遗忘机制 ─────────────────────
|
||||
|
||||
forget_context_graph() {
|
||||
echo "🧠 Phase 1.7: ContextGraph 遗忘机制"
|
||||
|
||||
[ ! -f ~/.openclaw/context-graph.json ] && echo " 无文件" && return
|
||||
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const g = JSON.parse(fs.readFileSync(process.env.HOME + '/.openclaw/context-graph.json', 'utf8'));
|
||||
|
||||
let removed = 0;
|
||||
const now = Date.now();
|
||||
const THRESHOLD = 90 * 86400 * 1000; // 90天
|
||||
|
||||
for (const [id, entity] of Object.entries(g.entities)) {
|
||||
// 遗忘条件:非 pinned + 最后观察 >90天 + 低优先级
|
||||
if (entity.pinned) continue;
|
||||
if (!entity.observations || entity.observations.length === 0) continue;
|
||||
|
||||
const lastObs = entity.observations[entity.observations.length - 1];
|
||||
const age = now - new Date(lastObs.timestamp).getTime();
|
||||
|
||||
const hasHighPriority = entity.observations.some(o =>
|
||||
o.priority === 'critical' || o.priority === 'high'
|
||||
);
|
||||
|
||||
if (age > THRESHOLD && !hasHighPriority) {
|
||||
delete g.entities[id];
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
// 清理孤立关系
|
||||
const before = g.relations.length;
|
||||
g.relations = g.relations.filter(r =>
|
||||
g.entities[r.from] && g.entities[r.to]
|
||||
);
|
||||
const relRemoved = before - g.relations.length;
|
||||
|
||||
fs.writeFileSync(process.env.HOME + '/.openclaw/context-graph.json', JSON.stringify(g, null, 2));
|
||||
console.log(' 遗忘实体: ' + removed + ' | 孤立关系: ' + relRemoved);
|
||||
" 2>/dev/null || echo " 跳过"
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* End-to-End Generator Benchmark v1
|
||||
*
|
||||
* 完整链路:需求 → 前端 → 后端 → 全栈 → Electron → Release
|
||||
* 10 个领域全量验证,输出 end-to-end-benchmark-v1.md
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(resolve(fileURLToPath(import.meta.url)));
|
||||
const WORKSPACE = resolve(__dirname, "..");
|
||||
const BENCH_ROOT = resolve(WORKSPACE, ".benchmark");
|
||||
const SCRIPTS = resolve(WORKSPACE, "scripts");
|
||||
const REPORT_PATH = resolve(WORKSPACE, "end-to-end-benchmark-v1.md");
|
||||
|
||||
const DOMAINS = [
|
||||
{ id: "petcare", input: "做一个宠物护理管理平台,宠物主人可以管理宠物档案、健康日程、日常记录和成长相册" },
|
||||
{ id: "crm", input: "做一个客户关系管理系统,支持客户管理、销售漏斗、跟进记录和数据分析仪表盘" },
|
||||
{ id: "inventory", input: "做一个库存管理系统,支持商品入库出库、库存盘点、供应商管理和库存预警" },
|
||||
{ id: "ticket", input: "做一个工单系统,支持工单创建、分配、处理流程、优先级管理和工单归档" },
|
||||
{ id: "blog-cms", input: "做一个博客内容管理系统,支持文章发布、分类标签、评论管理和媒体库" },
|
||||
{ id: "project-mgmt", input: "做一个项目管理系统,支持项目看板、任务分配、甘特图和团队协作" },
|
||||
{ id: "hr", input: "做一个人力资源管理系统,支持员工档案、考勤管理、招聘流程和绩效评估" },
|
||||
{ id: "asset", input: "做一个固定资产管理系统,支持资产登记、领用归还、折旧计算和盘点统计" },
|
||||
{ id: "course", input: "做一个在线课程管理系统,支持课程发布、章节管理、学员进度和作业批改" },
|
||||
{ id: "appointment", input: "做一个预约管理系统,支持服务项目、时间段预约、客户通知和预约统计" },
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function cmd(c, opts = {}) {
|
||||
try {
|
||||
return execSync(c, { cwd: WORKSPACE, encoding: "utf8", ...opts, stdio: opts.stdio ?? "pipe" });
|
||||
} catch (e) {
|
||||
return { error: e.message, stderr: e.stderr?.toString() || "", stdout: e.stdout?.toString() || "" };
|
||||
}
|
||||
}
|
||||
|
||||
function runAgent(script, args) {
|
||||
const t0 = Date.now();
|
||||
const result = cmd(`node ${join(SCRIPTS, script)} ${args}`);
|
||||
const ms = Date.now() - t0;
|
||||
if (result.error) {
|
||||
try { return { ms, ...JSON.parse(result.stdout?.trim() || "{}") }; } catch { return { ms, error: result.error }; }
|
||||
}
|
||||
try { return { ms, ...JSON.parse(result.trim()) }; } catch { return { ms, raw: result }; }
|
||||
}
|
||||
|
||||
function countFiles(dir) {
|
||||
try {
|
||||
const r = execSync(`find ${dir} -type f 2>/dev/null | wc -l`, { encoding: "utf8", cwd: WORKSPACE });
|
||||
return parseInt(r.trim(), 10);
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
function loadJSON(path) {
|
||||
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return {}; }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Validation
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function validateFrontend(dir) {
|
||||
if (!existsSync(dir)) return { pass: false, reason: "dir missing" };
|
||||
const checks = {
|
||||
packageJson: existsSync(join(dir, "package.json")),
|
||||
appDir: existsSync(join(dir, "app")),
|
||||
services: existsSync(join(dir, "services")) || existsSync(join(dir, "src", "services")),
|
||||
types: existsSync(join(dir, "types")) || existsSync(join(dir, "src", "types")),
|
||||
};
|
||||
const fileCount = countFiles(dir);
|
||||
const pass = checks.packageJson && checks.appDir && fileCount > 10;
|
||||
return { pass, fileCount, ...checks };
|
||||
}
|
||||
|
||||
function validateBackend(dir) {
|
||||
if (!existsSync(dir)) return { pass: false, reason: "dir missing" };
|
||||
const checks = {
|
||||
packageJson: existsSync(join(dir, "package.json")),
|
||||
routes: existsSync(join(dir, "src", "routes")),
|
||||
services: existsSync(join(dir, "src", "services")),
|
||||
auth: existsSync(join(dir, "src", "routes", "auth.ts")) || existsSync(join(dir, "src", "routes", "auth-route.ts")),
|
||||
db: existsSync(join(dir, "src", "db")) || existsSync(join(dir, "src", "schema")),
|
||||
};
|
||||
const fileCount = countFiles(dir);
|
||||
const pass = checks.packageJson && checks.routes && checks.services && fileCount > 10;
|
||||
return { pass, fileCount, ...checks };
|
||||
}
|
||||
|
||||
function validateFullstack(dir) {
|
||||
if (!existsSync(dir)) return { pass: false, reason: "dir missing" };
|
||||
const checks = {
|
||||
packageJson: existsSync(join(dir, "package.json")),
|
||||
web: existsSync(join(dir, "apps", "web")),
|
||||
api: existsSync(join(dir, "apps", "api")),
|
||||
shared: existsSync(join(dir, "packages")),
|
||||
};
|
||||
const fileCount = countFiles(dir);
|
||||
const pass = checks.packageJson && checks.web && checks.api && fileCount > 30;
|
||||
return { pass, fileCount, ...checks };
|
||||
}
|
||||
|
||||
function validateElectron(dir) {
|
||||
if (!existsSync(dir)) return { pass: false, reason: "dir missing" };
|
||||
const checks = {
|
||||
packageJson: existsSync(join(dir, "package.json")),
|
||||
main: existsSync(join(dir, "electron", "main.ts")),
|
||||
preload: existsSync(join(dir, "electron", "preload.ts")),
|
||||
ipc: existsSync(join(dir, "electron", "ipc.ts")),
|
||||
yml: existsSync(join(dir, "electron-builder.yml")),
|
||||
};
|
||||
const fileCount = countFiles(dir);
|
||||
const pass = checks.packageJson && checks.main && checks.preload && fileCount >= 8;
|
||||
return { pass, fileCount, ...checks };
|
||||
}
|
||||
|
||||
function validateRelease(dir) {
|
||||
if (!existsSync(dir)) return { pass: false, reason: "dir missing" };
|
||||
const rDir = join(dir, "release");
|
||||
const checks = {
|
||||
versionJson: existsSync(join(rDir, "version.json")),
|
||||
manifest: existsSync(join(rDir, "manifests", "manifest.json")),
|
||||
checksums: existsSync(join(rDir, "checksums", "checksums.txt")),
|
||||
releaseNotes: existsSync(join(rDir, "release-notes", "release-notes.md")),
|
||||
buildInfo: existsSync(join(rDir, "build-info.json")),
|
||||
windows: existsSync(join(rDir, "windows")),
|
||||
macos: existsSync(join(rDir, "macos")),
|
||||
linux: existsSync(join(rDir, "linux")),
|
||||
};
|
||||
// Validate content
|
||||
if (checks.versionJson) {
|
||||
try {
|
||||
const v = JSON.parse(readFileSync(join(rDir, "version.json"), "utf8"));
|
||||
checks.versionValid = !!(v.name && v.version && v.platforms);
|
||||
} catch { checks.versionValid = false; }
|
||||
}
|
||||
if (checks.manifest) {
|
||||
try {
|
||||
const m = JSON.parse(readFileSync(join(rDir, "manifests", "manifest.json"), "utf8"));
|
||||
checks.manifestValid = !!(m.project && m.files?.length > 0);
|
||||
} catch { checks.manifestValid = false; }
|
||||
}
|
||||
if (checks.checksums) {
|
||||
const content = readFileSync(join(rDir, "checksums", "checksums.txt"), "utf8");
|
||||
const lines = content.trim().split("\n").filter(l => l.length > 0);
|
||||
checks.checksumCount = lines.length;
|
||||
checks.checksumsValid = lines.every(l => l.split(/\s+/)[0]?.length === 64);
|
||||
}
|
||||
if (checks.releaseNotes) {
|
||||
const notes = readFileSync(join(rDir, "release-notes", "release-notes.md"), "utf8");
|
||||
checks.notesValid = notes.includes("Version") && notes.includes("Installation");
|
||||
}
|
||||
if (checks.buildInfo) {
|
||||
try {
|
||||
const bi = JSON.parse(readFileSync(join(rDir, "build-info.json"), "utf8"));
|
||||
checks.buildInfoValid = !!(bi.nodeVersion && bi.generatorVersion);
|
||||
} catch { checks.buildInfoValid = false; }
|
||||
}
|
||||
// Platform file counts
|
||||
for (const p of ["windows", "macos", "linux"]) {
|
||||
if (checks[p]) {
|
||||
try { checks[p + "Count"] = readdirSync(join(rDir, p)).length; } catch { checks[p + "Count"] = 0; }
|
||||
}
|
||||
}
|
||||
const fileCount = countFiles(dir);
|
||||
const pass = Object.values(checks).every(v => v !== false && v !== 0);
|
||||
return { pass, fileCount, ...checks };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Pipeline
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function runPipeline(domain) {
|
||||
const B = join(BENCH_ROOT, domain.id);
|
||||
mkdirSync(B, { recursive: true });
|
||||
|
||||
const result = { id: domain.id, input: domain.input, stages: {} };
|
||||
|
||||
// SF-01: PRD
|
||||
const s1 = runAgent("project-intake-agent.mjs", `--input "${domain.input}" --output "${join(B, "prd.json")}"`);
|
||||
result.stages.prd = { ms: s1.ms, projectName: s1.projectName, domain: s1.domain, error: s1.error };
|
||||
result.projectName = s1.projectName;
|
||||
|
||||
// SF-02: Architecture
|
||||
const s2 = runAgent("architecture-agent.mjs", `--input "${join(B, "prd.json")}" --output "${join(B, "arch.json")}"`);
|
||||
result.stages.arch = { ms: s2.ms, modules: s2.moduleCount, error: s2.error };
|
||||
|
||||
// SF-03: Frontend
|
||||
const feOut = join(B, "frontend");
|
||||
const s3 = runAgent("frontend-builder-agent.mjs", `--prd "${join(B, "prd.json")}" --arch "${join(B, "arch.json")}" --output "${feOut}"`);
|
||||
const feVal = validateFrontend(feOut);
|
||||
result.stages.frontend = { ms: s3.ms, files: s3.stats?.totalFiles || feVal.fileCount, pass: feVal.pass, error: s3.error };
|
||||
|
||||
// SF-04: Backend
|
||||
const beOut = join(B, "backend");
|
||||
const s4 = runAgent("backend-builder-agent.mjs", `--prd "${join(B, "prd.json")}" --arch "${join(B, "arch.json")}" --output "${beOut}"`);
|
||||
const beVal = validateBackend(beOut);
|
||||
result.stages.backend = { ms: s4.ms, files: s4.stats?.totalFiles || beVal.fileCount, pass: beVal.pass, error: s4.error };
|
||||
|
||||
// SF-05: Fullstack
|
||||
const fsOut = join(B, "fullstack");
|
||||
const s5 = runAgent("fullstack-composer-agent.mjs", `--prd "${join(B, "prd.json")}" --arch "${join(B, "arch.json")}" --output "${fsOut}"`);
|
||||
const fsVal = validateFullstack(fsOut);
|
||||
result.stages.fullstack = { ms: s5.ms, files: s5.stats?.totalFiles || fsVal.fileCount, pass: fsVal.pass, error: s5.error };
|
||||
|
||||
// SF-06: Electron
|
||||
const elOut = join(B, "electron");
|
||||
const s6 = runAgent("electron-builder-agent.mjs", `--input "${fsOut}" --output "${elOut}" --prd "${join(B, "prd.json")}"`);
|
||||
const elVal = validateElectron(elOut);
|
||||
result.stages.electron = { ms: s6.ms, files: s6.stats?.totalFiles || elVal.fileCount, pass: elVal.pass, error: s6.error };
|
||||
|
||||
// SF-07: Release
|
||||
const rlOut = join(B, "release");
|
||||
const s7 = runAgent("release-builder-agent.mjs", `--input "${fsOut}" --output "${rlOut}"`);
|
||||
const rlVal = validateRelease(rlOut);
|
||||
result.stages.release = { ms: s7.ms, files: s7.stats?.totalFiles || rlVal.fileCount, pass: rlVal.pass, error: s7.error };
|
||||
|
||||
// Totals
|
||||
result.totalMs = Object.values(result.stages).reduce((s, v) => s + (v.ms || 0), 0);
|
||||
result.totalFiles = Object.values(result.stages).reduce((s, v) => s + (v.files || 0), 0);
|
||||
result.allPass = Object.values(result.stages).every(v => v.pass !== false);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Report
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateReport(allResults) {
|
||||
const L = [];
|
||||
const h = (t) => { L.push(t); return L; };
|
||||
const now = new Date();
|
||||
|
||||
h(`# End-to-End Generator Benchmark v1`);
|
||||
h(``);
|
||||
h(`> ${now.toISOString()}`);
|
||||
h(`> 链路: 需求 → 前端 → 后端 → 全栈 → Electron → Release`);
|
||||
h(`> 领域: ${allResults.length} 个`);
|
||||
h(``);
|
||||
|
||||
// ── Summary ──
|
||||
const passCount = allResults.filter(r => r.allPass).length;
|
||||
const totalFiles = allResults.reduce((s, r) => s + r.totalFiles, 0);
|
||||
const avgMs = (allResults.reduce((s, r) => s + r.totalMs, 0) / allResults.length).toFixed(0);
|
||||
|
||||
h(`## Summary`);
|
||||
h(``);
|
||||
h(`| Metric | Value |`);
|
||||
h(`|--------|-------|`);
|
||||
h(`| Domains | ${allResults.length} |`);
|
||||
h(`| PASS | ${passCount}/${allResults.length} |`);
|
||||
h(`| FAIL | ${allResults.length - passCount}/${allResults.length} |`);
|
||||
h(`| Total Files Generated | ${totalFiles} |`);
|
||||
h(`| Avg Pipeline Time | ${avgMs}ms |`);
|
||||
h(``);
|
||||
|
||||
// ── Domain Results ──
|
||||
h(`## Domain Results`);
|
||||
h(``);
|
||||
h(`| Domain | PRD | Arch | Frontend | Backend | Fullstack | Electron | Release | Files | Time | Result |`);
|
||||
h(`|--------|-----|------|----------|---------|-----------|----------|---------|-------|------|--------|`);
|
||||
|
||||
for (const r of allResults) {
|
||||
const s = r.stages;
|
||||
const p = (v) => v.pass === false ? "❌" : "✅";
|
||||
const t = (v) => v.ms ? `${v.ms}ms` : "—";
|
||||
h(`| ${r.id} | ${t(s.prd)} | ${t(s.arch)} | ${p(s.frontend)} ${s.frontend.files || 0}f | ${p(s.backend)} ${s.backend.files || 0}f | ${p(s.fullstack)} ${s.fullstack.files || 0}f | ${p(s.electron)} ${s.electron.files || 0}f | ${p(s.release)} ${s.release.files || 0}f | ${r.totalFiles} | ${r.totalMs}ms | **${r.allPass ? "PASS" : "FAIL"}** |`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
// ── Per-Stage Stats ──
|
||||
h(`## Per-Stage Statistics`);
|
||||
h(``);
|
||||
const stages = ["prd", "arch", "frontend", "backend", "fullstack", "electron", "release"];
|
||||
const stageNames = ["SF-01 PRD", "SF-02 Arch", "SF-03 Frontend", "SF-04 Backend", "SF-05 Fullstack", "SF-06 Electron", "SF-07 Release"];
|
||||
|
||||
h(`| Stage | Avg Time | Min | Max | Avg Files | Pass |`);
|
||||
h(`|-------|----------|-----|-----|-----------|------|`);
|
||||
for (let i = 0; i < stages.length; i++) {
|
||||
const key = stages[i];
|
||||
const times = allResults.map(r => r.stages[key]?.ms || 0);
|
||||
const files = allResults.map(r => r.stages[key]?.files || 0);
|
||||
const passes = allResults.filter(r => r.stages[key]?.pass !== false).length;
|
||||
const avg = (times.reduce((a, b) => a + b, 0) / times.length).toFixed(0);
|
||||
const min = Math.min(...times);
|
||||
const max = Math.max(...times);
|
||||
const avgF = (files.reduce((a, b) => a + b, 0) / files.length).toFixed(0);
|
||||
h(`| ${stageNames[i]} | ${avg}ms | ${min}ms | ${max}ms | ${avgF} | ${passes}/${allResults.length} |`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
// ── File Generation ──
|
||||
h(`## File Generation`);
|
||||
h(``);
|
||||
h(`| Domain | Frontend | Backend | Fullstack | Electron | Release | Total |`);
|
||||
h(`|--------|----------|---------|-----------|----------|---------|-------|`);
|
||||
for (const r of allResults) {
|
||||
const s = r.stages;
|
||||
h(`| ${r.id} | ${s.frontend.files || 0} | ${s.backend.files || 0} | ${s.fullstack.files || 0} | ${s.electron.files || 0} | ${s.release.files || 0} | ${r.totalFiles} |`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
// ── Failure Details ──
|
||||
const failures = allResults.filter(r => !r.allPass);
|
||||
if (failures.length > 0) {
|
||||
h(`## Failure Details`);
|
||||
h(``);
|
||||
for (const r of failures) {
|
||||
h(`### ${r.id}`);
|
||||
h(``);
|
||||
for (const [stage, data] of Object.entries(r.stages)) {
|
||||
if (data.pass === false) {
|
||||
h(`- **${stage}:** FAIL${data.error ? " — " + data.error : ""}`);
|
||||
}
|
||||
}
|
||||
h(``);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Final Status ──
|
||||
h(`## Final Status`);
|
||||
h(``);
|
||||
if (passCount === allResults.length) {
|
||||
h(`🎉 **End-to-End Generator — PASS** (${passCount}/${allResults.length})`);
|
||||
h(``);
|
||||
h(`完整链路 需求 → 前端 → 后端 → 全栈 → Electron → Release 全部通过。`);
|
||||
h(`系统具备跨领域泛化能力,可作为通用 Web 全栈 + 桌面应用生成器。`);
|
||||
} else {
|
||||
h(`⚠️ **End-to-End Generator — PARTIAL** (${passCount}/${allResults.length})`);
|
||||
h(``);
|
||||
h(`Failed: ${failures.map(r => r.id).join(", ")}`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
h(`---`);
|
||||
h(`*End-to-End Generator Benchmark v1 — ${now.toISOString()}*`);
|
||||
|
||||
return L.join("\n");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Main
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
async function main() {
|
||||
console.log(`\n🧪 End-to-End Generator Benchmark v1`);
|
||||
console.log(` ${DOMAINS.length} domains × 7 stages = ${DOMAINS.length * 7} checks`);
|
||||
console.log(` 链路: 需求 → 前端 → 后端 → 全栈 → Electron → Release\n`);
|
||||
|
||||
const allResults = [];
|
||||
|
||||
for (const domain of DOMAINS) {
|
||||
console.log(`${"─".repeat(50)}`);
|
||||
console.log(`🏗️ ${domain.id}`);
|
||||
|
||||
const result = runPipeline(domain);
|
||||
allResults.push(result);
|
||||
|
||||
const status = result.allPass ? "✅ PASS" : "❌ FAIL";
|
||||
console.log(` ${result.projectName || "?"} | ${result.totalFiles} files | ${result.totalMs}ms | ${status}`);
|
||||
}
|
||||
|
||||
const report = generateReport(allResults);
|
||||
writeFileSync(REPORT_PATH, report, "utf8");
|
||||
|
||||
const passCount = allResults.filter(r => r.allPass).length;
|
||||
console.log(`\n${"═".repeat(50)}`);
|
||||
console.log(`📊 ${passCount}/${DOMAINS.length} PASS`);
|
||||
console.log(`📄 ${REPORT_PATH}`);
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error("Benchmark failed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# extract-skill — 从已完成任务中提取可复用能力
|
||||
# 用法:bash scripts/extract-skill.sh <skill-name> "<description>"
|
||||
# 示例:bash scripts/extract-skill.sh springboot-api "SpringBoot 接口开发流程"
|
||||
#
|
||||
# 输出:tools/<skill-name>.md
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "用法: bash scripts/extract-skill.sh <skill-name> \"<description>\""
|
||||
echo "示例: bash scripts/extract-skill.sh project-scan \"项目结构扫描流程\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SKILL_NAME="$1"
|
||||
SKILL_DESC="$2"
|
||||
SKILL_FILE="tools/${SKILL_NAME}.md"
|
||||
|
||||
if [ -f "$SKILL_FILE" ]; then
|
||||
echo "⚠️ Skill 已存在: $SKILL_FILE"
|
||||
echo " 使用 extract-skill 更新已有 skill:bash scripts/extract-skill.sh ${SKILL_NAME} \"$(cat "$SKILL_FILE" | head -1)\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$SKILL_FILE" << EOF
|
||||
# ${SKILL_NAME} — ${SKILL_DESC}
|
||||
|
||||
> 从 Hermes Agent 工作流理念提取
|
||||
|
||||
## 适用场景
|
||||
|
||||
描述什么情况下使用这个 skill。
|
||||
|
||||
## 输入
|
||||
|
||||
- 输入 1:描述
|
||||
- 输入 2:描述
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 第一步
|
||||
2. 第二步
|
||||
3. 第三步
|
||||
|
||||
## 常见风险
|
||||
|
||||
- 风险 1
|
||||
- 风险 2
|
||||
|
||||
## 验证方式
|
||||
|
||||
如何确认任务完成且正确。
|
||||
|
||||
## 输出格式
|
||||
|
||||
描述 task 完成时的交付物格式。
|
||||
EOF
|
||||
|
||||
echo "✅ Skill 已创建: tools/${SKILL_NAME}.md"
|
||||
echo ""
|
||||
echo "下一步:"
|
||||
echo " 1. 编辑 $SKILL_FILE 填入具体内容"
|
||||
echo " 2. 在 MEMORY.md 的记录"已固化的能力"中引用它"
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Factory Router — V2 Protocol Stack → Full-stack Factory Bridge
|
||||
*
|
||||
* Routes a structured PRD to the appropriate factory chain.
|
||||
* No factory logic lives here — only routing decisions.
|
||||
*
|
||||
* Current factories:
|
||||
* - fullstack → SF-01 (project-intake) + SF-02 (architecture) + SF-03~06 (fullstack-composer)
|
||||
*
|
||||
* Future factories (routing placeholder):
|
||||
* - research → 科研工厂
|
||||
* - writing → 写作工厂
|
||||
* - analysis → 分析工厂
|
||||
*
|
||||
* @module factory-router
|
||||
*/
|
||||
|
||||
/**
|
||||
* Route a PRD to the appropriate factory.
|
||||
* Currently only "fullstack" is implemented.
|
||||
*
|
||||
* @param {object} prd — Structured PRD from SF-01
|
||||
* @param {object} opts
|
||||
* @param {object} opts.arch — Architecture from SF-02 (optional, auto-generated if missing)
|
||||
* @param {string} opts.factory — Force a factory ("fullstack" | "research" | "writing" | "analysis")
|
||||
* @returns {{ factory: string, factoryFn: string, needsArch: boolean }}
|
||||
*/
|
||||
export function route(prd, opts = {}) {
|
||||
const domain = prd.domain || "generic";
|
||||
|
||||
// Resolve factory name
|
||||
let factory = opts.factory || _inferFactory(domain, prd);
|
||||
|
||||
// All factories need architecture → default to fullstack pipeline for now
|
||||
if (factory === "fullstack") {
|
||||
return {
|
||||
factory: "fullstack",
|
||||
factoryFn: "./fullstack-composer-agent.mjs",
|
||||
needsArch: !opts.arch,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown or future factory → default to fullstack
|
||||
return {
|
||||
factory: "fullstack",
|
||||
factoryFn: "./fullstack-composer-agent.mjs",
|
||||
needsArch: !opts.arch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer factory type from domain and PRD content.
|
||||
*
|
||||
* @param {string} domain
|
||||
* @param {object} prd
|
||||
* @returns {string} factory name
|
||||
*/
|
||||
function _inferFactory(domain, prd) {
|
||||
// Research domains → research factory (future)
|
||||
const researchDomains = ["research", "academic", "scientific", "paper", "thesis"];
|
||||
if (researchDomains.includes(domain)) return "research";
|
||||
|
||||
// Writing domains → writing factory (future)
|
||||
const writingDomains = ["writing", "blog", "article", "news"];
|
||||
if (writingDomains.includes(domain)) return "writing";
|
||||
|
||||
// Analysis domains → analysis factory (future)
|
||||
const analysisDomains = ["analytics", "dashboard", "report"];
|
||||
if (analysisDomains.includes(domain)) return "analysis";
|
||||
|
||||
// Default: fullstack factory
|
||||
return "fullstack";
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a factory module. Keeps dynamic import lazy.
|
||||
*
|
||||
* @param {string} factoryFn — Path to factory script (relative to scripts/)
|
||||
* @returns {Promise<object>} factory module
|
||||
*/
|
||||
export async function loadFactory(factoryFn) {
|
||||
return await import(factoryFn);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,764 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Fullstack Composer Agent — SF-05
|
||||
*
|
||||
* 组合 frontend-builder-agent (SF-03) + backend-builder-agent (SF-04),
|
||||
* 从同一个 SF-02 RequirementPackage 生成完整可运行的全栈项目。
|
||||
*
|
||||
* 输出结构:
|
||||
* apps/web/ — Next.js 前端 (SF-03)
|
||||
* apps/api/ — Fastify 后端 (SF-04)
|
||||
* packages/shared-types/ — 前后端共享类型
|
||||
* packages/shared-config/ — 前后端共享配置
|
||||
* 根目录 — workspace package.json / README / scripts
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/fullstack-composer-agent.mjs --prd <path> --arch <path> [options]
|
||||
* node scripts/fullstack-composer-agent.mjs --input-text "<需求>" [options]
|
||||
*
|
||||
* Options:
|
||||
* --prd <path> PRD JSON(SF-01 输出)
|
||||
* --arch <path> Architecture JSON(SF-02 输出)
|
||||
* --input-text <text> 直接传入需求
|
||||
* --output <dir> 输出目录(default: fullstack/)
|
||||
* --verbose 详细输出
|
||||
* --help 显示帮助
|
||||
*
|
||||
* @module fullstack-composer-agent
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createContract, toCamel, tsType as contractTsType } from "./model-contract.mjs";
|
||||
|
||||
const __dirname = dirname(resolve(fileURLToPath(import.meta.url)));
|
||||
const WORKSPACE = resolve(__dirname, "..");
|
||||
const DEFAULT_OUTPUT = resolve(WORKSPACE, "fullstack");
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 1. Shared Types Generator
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function sqliteTypeForTs(field) {
|
||||
const t = (field.type || "").toUpperCase();
|
||||
if (t.includes("INT") || t.includes("SERIAL") || t.includes("BIGINT") || t.includes("DECIMAL") || t.includes("NUMERIC") || t.includes("FLOAT") || t.includes("DOUBLE") || t.includes("REAL")) return "number";
|
||||
if (t.includes("BOOL")) return "boolean";
|
||||
return "string";
|
||||
}
|
||||
|
||||
function isRequired(field) {
|
||||
const c = (field.constraints || "").toUpperCase();
|
||||
return c.includes("NOT NULL") || c.includes("PK");
|
||||
}
|
||||
|
||||
function pascalCase(s) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1).replace(/[-_]([a-zA-Z])/g, (_, c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Words where final "s" is part of the root, not a plural marker. */
|
||||
const SINGULAR_EXCEPTIONS = new Set([
|
||||
"status", "bus", "campus", "focus", "bonus", "virus", "genius",
|
||||
"census", "ensus", "consensus", "apparatus", "fetus", "hiatus",
|
||||
]);
|
||||
|
||||
function singularize(s) {
|
||||
if (s.endsWith("ies")) return s.slice(0, -3) + "y";
|
||||
if (s.endsWith("ses") || s.endsWith("xes") || s.endsWith("ches") || s.endsWith("shes")) return s.slice(0, -2);
|
||||
if (s.endsWith("s") && !s.endsWith("ss")) {
|
||||
const lastSeg = s.includes("_") ? s.split("_").pop() : s;
|
||||
if (SINGULAR_EXCEPTIONS.has(lastSeg)) return s;
|
||||
return s.slice(0, -1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate shared types that both frontend and backend can import.
|
||||
* Includes entity types + API response wrappers only.
|
||||
*/
|
||||
function generateSharedTypes(contract) {
|
||||
const entities = contract.entities || [];
|
||||
const lines = [
|
||||
`// Shared types for fullstack project`,
|
||||
`// Auto-generated — used by both apps/web and apps/api`,
|
||||
``,
|
||||
`// ─── Entities ────────────────────────────────────────`,
|
||||
];
|
||||
|
||||
const generated = new Set();
|
||||
|
||||
// Include User and all entities from contract
|
||||
for (const entity of entities) {
|
||||
const name = entity.name;
|
||||
if (generated.has(name)) continue;
|
||||
generated.add(name);
|
||||
|
||||
lines.push(`export interface ${name} {`);
|
||||
for (const f of entity.fields) {
|
||||
const fname = toCamel(contract, f.name);
|
||||
const optional = f.required ? "" : "?";
|
||||
lines.push(` ${fname}${optional}: ${contractTsType(f)};`);
|
||||
}
|
||||
lines.push(`}`);
|
||||
lines.push(``);
|
||||
}
|
||||
|
||||
// ─── API Response Wrappers ──────────────────────────
|
||||
lines.push(`// ─── API Response Wrappers ───────────────────────────`);
|
||||
lines.push(`export interface ApiResponse<T> {`);
|
||||
lines.push(` data: T;`);
|
||||
lines.push(` message?: string;`);
|
||||
lines.push(`}`);
|
||||
lines.push(``);
|
||||
lines.push(`export interface PaginatedResponse<T> {`);
|
||||
lines.push(` data: T[];`);
|
||||
lines.push(` total: number;`);
|
||||
lines.push(` page: number;`);
|
||||
lines.push(` pageSize: number;`);
|
||||
lines.push(`}`);
|
||||
lines.push(``);
|
||||
lines.push(`export interface ErrorResponse {`);
|
||||
lines.push(` error: string;`);
|
||||
lines.push(` message: string;`);
|
||||
lines.push(` statusCode: number;`);
|
||||
lines.push(`}`);
|
||||
lines.push(``);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 2. Shared Config Generator
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateSharedConfig() {
|
||||
return {
|
||||
"src/index.ts": `// Shared configuration for fullstack project
|
||||
|
||||
/** API base URL — reads from env or defaults */
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
|
||||
/** API prefix for all endpoints */
|
||||
export const API_PREFIX = "/api";
|
||||
|
||||
/** Full API URL */
|
||||
export const API_URL = \`\${API_BASE_URL}\${API_PREFIX}\`;
|
||||
|
||||
/** Auth token storage key */
|
||||
export const AUTH_TOKEN_KEY = "auth_token";
|
||||
|
||||
/** Default page size for paginated endpoints */
|
||||
export const DEFAULT_PAGE_SIZE = 20;
|
||||
`,
|
||||
"package.json": JSON.stringify({
|
||||
name: "@shared/config",
|
||||
version: "0.1.0",
|
||||
private: true,
|
||||
main: "./src/index.ts",
|
||||
types: "./src/index.ts",
|
||||
}, null, 2),
|
||||
"tsconfig.json": JSON.stringify({
|
||||
compilerOptions: {
|
||||
target: "ES2022",
|
||||
module: "ESNext",
|
||||
moduleResolution: "bundler",
|
||||
strict: true,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
declaration: true,
|
||||
outDir: "./dist",
|
||||
},
|
||||
include: ["src"],
|
||||
}, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 3. Shared Types Package Generator
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateSharedTypesPackage(contract) {
|
||||
return {
|
||||
"src/index.ts": generateSharedTypes(contract),
|
||||
"package.json": JSON.stringify({
|
||||
name: "@shared/types",
|
||||
version: "0.1.0",
|
||||
private: true,
|
||||
main: "./src/index.ts",
|
||||
types: "./src/index.ts",
|
||||
}, null, 2),
|
||||
"tsconfig.json": JSON.stringify({
|
||||
compilerOptions: {
|
||||
target: "ES2022",
|
||||
module: "ESNext",
|
||||
moduleResolution: "bundler",
|
||||
strict: true,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
declaration: true,
|
||||
outDir: "./dist",
|
||||
},
|
||||
include: ["src"],
|
||||
}, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 4. Root Files Generator
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateRootFiles(projectName, prdSummary, contract) {
|
||||
const safeName = (projectName || "fullstack").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
||||
|
||||
const entities = contract.entities || [];
|
||||
const tableList = entities.map(e => `- **${e.table}** — ${e.description || ""}`).join("\n");
|
||||
|
||||
return {
|
||||
"package.json": JSON.stringify({
|
||||
name: safeName,
|
||||
version: "0.1.0",
|
||||
private: true,
|
||||
workspaces: [
|
||||
"apps/*",
|
||||
"packages/*",
|
||||
],
|
||||
scripts: {
|
||||
dev: "node scripts/dev.mjs",
|
||||
build: "node scripts/build.mjs",
|
||||
"dev:web": "npm run dev -w apps/web",
|
||||
"dev:api": "npm run dev -w apps/api",
|
||||
"build:web": "npm run build -w apps/web",
|
||||
"build:api": "npm run build -w apps/api",
|
||||
test: "npm run test -w apps/api",
|
||||
lint: "npm run lint -w apps/web",
|
||||
},
|
||||
}, null, 2),
|
||||
|
||||
".env.example": `# ─── API Server ───────────────────────────────────
|
||||
API_PORT=3001
|
||||
API_HOST=0.0.0.0
|
||||
NODE_ENV=development
|
||||
DATABASE_URL=./data/app.db
|
||||
JWT_SECRET=change-me-in-production
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# ─── Web Client ───────────────────────────────────
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
WEB_PORT=3000
|
||||
`,
|
||||
|
||||
"README.md": `# ${projectName} — Fullstack Project
|
||||
|
||||
> ${prdSummary || "Auto-generated fullstack application"}
|
||||
|
||||
## Architecture
|
||||
|
||||
\`\`\`
|
||||
apps/
|
||||
├── web/ # Next.js 15 + TypeScript + Tailwind CSS
|
||||
└── api/ # Fastify 5 + TypeScript + SQLite (sql.js)
|
||||
|
||||
packages/
|
||||
├── shared-types/ # @shared/types — shared TypeScript interfaces
|
||||
└── shared-config/ # @shared/config — shared configuration
|
||||
\`\`\`
|
||||
|
||||
## Quick Start
|
||||
|
||||
\`\`\`bash
|
||||
# Install all dependencies (root + workspaces)
|
||||
npm install
|
||||
|
||||
# Start both frontend and backend in dev mode
|
||||
npm run dev
|
||||
|
||||
# Or start individually
|
||||
npm run dev:web # http://localhost:3000
|
||||
npm run dev:api # http://localhost:3001
|
||||
\`\`\`
|
||||
|
||||
## Build
|
||||
|
||||
\`\`\`bash
|
||||
# Build everything
|
||||
npm run build
|
||||
|
||||
# Or individually
|
||||
npm run build:web
|
||||
npm run build:api
|
||||
\`\`\`
|
||||
|
||||
## Testing
|
||||
|
||||
\`\`\`bash
|
||||
# Run API tests
|
||||
npm test
|
||||
\`\`\`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Auth
|
||||
- \`POST /api/auth/register\`
|
||||
- \`POST /api/auth/login\`
|
||||
- \`GET /api/auth/me\`
|
||||
|
||||
### Resources
|
||||
${entities.map(e => {
|
||||
const tn = e.table;
|
||||
return `#### ${tn}\n- \`GET /api/${tn}\` — List\n- \`GET /api/${tn}/:id\` — Get\n- \`POST /api/${tn}\` — Create\n- \`PUT /api/${tn}/:id\` — Update\n- \`DELETE /api/${tn}/:id\` — Delete`;
|
||||
}).join("\n\n")}
|
||||
|
||||
### System
|
||||
- \`GET /api/health\` — Health check
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See \`.env.example\` for all available configuration.
|
||||
`,
|
||||
|
||||
".gitignore": `node_modules/
|
||||
dist/
|
||||
.next/
|
||||
data/
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 5. Scripts Generator
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateScripts() {
|
||||
return {
|
||||
"scripts/dev.mjs": `#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Dev script — starts both web and api in parallel.
|
||||
* Usage: node scripts/dev.mjs
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
function start(name, command, args, cwd) {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
env: { ...process.env, FORCE_COLOR: "1" },
|
||||
});
|
||||
child.on("error", (err) => console.error(\`[\${name}] Failed: \${err.message}\`));
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0 && code !== null) console.error(\`[\${name}] Exited with code \${code}\`);
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
const ROOT = new URL("..", import.meta.url).pathname;
|
||||
|
||||
console.log("🚀 Starting fullstack dev servers...\\n");
|
||||
|
||||
const api = start("api", "npm", ["run", "dev"], \`\${ROOT}/apps/api\`);
|
||||
const web = start("web", "npm", ["run", "dev"], \`\${ROOT}/apps/web\`);
|
||||
|
||||
process.on("SIGINT", () => { api.kill(); web.kill(); process.exit(0); });
|
||||
process.on("SIGTERM", () => { api.kill(); web.kill(); process.exit(0); });
|
||||
`,
|
||||
|
||||
"scripts/build.mjs": `#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script — builds both web and api.
|
||||
* Usage: node scripts/build.mjs
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const ROOT = new URL("..", import.meta.url).pathname;
|
||||
|
||||
function run(cmd, cwd) {
|
||||
console.log(\`\\n🔨 \${cmd} (in \${cwd})\`);
|
||||
execSync(cmd, { cwd, stdio: "inherit" });
|
||||
}
|
||||
|
||||
console.log("🏗️ Building fullstack project...\\n");
|
||||
|
||||
try {
|
||||
run("npm run build", \`\${ROOT}/apps/api\`);
|
||||
run("npm run build", \`\${ROOT}/apps/web\`);
|
||||
console.log("\\n✅ Build complete!");
|
||||
} catch (e) {
|
||||
console.error("\\n❌ Build failed:", e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 6. Frontend Post-Processor
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Adapt the generated frontend to work in a monorepo with shared packages.
|
||||
* - Add @shared/types and @shared/config as dependencies
|
||||
* - Update services/api.ts to use @shared/config for API base URL
|
||||
*/
|
||||
function postProcessFrontend(files, projectName) {
|
||||
const modified = { ...files };
|
||||
const baseName = (projectName || "app").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
||||
|
||||
// ── Update package.json ──
|
||||
if (modified["package.json"]) {
|
||||
const pkg = JSON.parse(modified["package.json"]);
|
||||
pkg.name = `${baseName}-web`;
|
||||
pkg.dependencies = pkg.dependencies || {};
|
||||
pkg.dependencies["@shared/types"] = "*";
|
||||
pkg.dependencies["@shared/config"] = "*";
|
||||
modified["package.json"] = JSON.stringify(pkg, null, 2);
|
||||
}
|
||||
|
||||
// ── Update services/api.ts (after prefixSrc, located at src/services/api.ts) ──
|
||||
if (modified["src/services/api.ts"]) {
|
||||
modified["src/services/api.ts"] = modified["src/services/api.ts"].replace(
|
||||
/(const API_BASE = .*?;)/s,
|
||||
`import { API_BASE_URL, API_PREFIX } from "@shared/config";
|
||||
|
||||
const API_BASE = \`\${API_BASE_URL}\${API_PREFIX}\`;`
|
||||
);
|
||||
}
|
||||
|
||||
// ── Create a shared-types re-export in src/types ──
|
||||
// We keep domain-specific types but re-export shared types
|
||||
if (modified["src/types/index.ts"]) {
|
||||
modified["src/types/index.ts"] = `// Re-export shared types for convenience
|
||||
export type {
|
||||
User,
|
||||
ApiResponse,
|
||||
PaginatedResponse,
|
||||
ErrorResponse,
|
||||
} from "@shared/types";
|
||||
|
||||
${modified["src/types/index.ts"]}
|
||||
`;
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 7. Backend Post-Processor
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function postProcessBackend(files, projectName, contract) {
|
||||
const modified = { ...files };
|
||||
const baseName = (projectName || "app").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
||||
const entities = contract.entities || [];
|
||||
|
||||
// ── Update package.json ──
|
||||
if (modified["package.json"]) {
|
||||
const pkg = JSON.parse(modified["package.json"]);
|
||||
pkg.name = `${baseName}-api`;
|
||||
pkg.dependencies = pkg.dependencies || {};
|
||||
pkg.dependencies["@shared/types"] = "*";
|
||||
pkg.dependencies["@shared/config"] = "*";
|
||||
modified["package.json"] = JSON.stringify(pkg, null, 2);
|
||||
}
|
||||
|
||||
// ── Rewrite types to use @shared/types for entities & API wrappers ──
|
||||
if (modified["src/types/index.ts"]) {
|
||||
let content = modified["src/types/index.ts"];
|
||||
|
||||
// Build set of entity type names from contract
|
||||
const entityNames = new Set(["User"]);
|
||||
for (const entity of entities.filter(e => e.table !== "users")) {
|
||||
entityNames.add(entity.name);
|
||||
}
|
||||
|
||||
// Remove specific duplicate type declarations (blocks ending with })
|
||||
const duplicateTypes = [...entityNames, "ApiResponse", "PaginatedResponse", "ErrorResponse"];
|
||||
for (const typeName of duplicateTypes) {
|
||||
// Remove `export interface TypeName ... }` including generics
|
||||
content = content.replace(
|
||||
new RegExp(`export interface ${typeName}(<[^>]*>)?\\s*\\{[^}]*\\}\\n\\n`, "g"),
|
||||
""
|
||||
);
|
||||
}
|
||||
// Clean extra blank lines
|
||||
content = content.replace(/\n{3,}/g, "\n\n");
|
||||
|
||||
// Build re-export list dynamically from schema
|
||||
const reExports = [...entityNames];
|
||||
const reExportLines = reExports.map(n => ` ${n},`).join("\n");
|
||||
|
||||
// Use explicit import + re-export pattern for reliable TS resolution
|
||||
const entityImportNames = reExports.join(",\n ");
|
||||
modified["src/types/index.ts"] = `// Import and re-export shared entity types
|
||||
import type {
|
||||
${entityImportNames},
|
||||
ApiResponse,
|
||||
PaginatedResponse,
|
||||
ErrorResponse,
|
||||
} from "@shared/types";
|
||||
|
||||
export type {
|
||||
${entityImportNames},
|
||||
ApiResponse,
|
||||
PaginatedResponse,
|
||||
ErrorResponse,
|
||||
};
|
||||
|
||||
${content}`;
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 8. Main Composer Function
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Compose a fullstack project from PRD + Architecture.
|
||||
*
|
||||
* @param {object} prd — SF-01 PRD
|
||||
* @param {object} arch — SF-02 Architecture
|
||||
* @returns {object} { files, stats, error, message }
|
||||
*/
|
||||
export async function composeFullstack(prd, arch) {
|
||||
if (!prd || prd.error) {
|
||||
return { error: "INVALID_PRD", message: "Invalid or missing PRD", files: {}, stats: {} };
|
||||
}
|
||||
if (!arch || arch.error) {
|
||||
return { error: "INVALID_ARCH", message: "Invalid or missing Architecture", files: {}, stats: {} };
|
||||
}
|
||||
|
||||
const projectName = prd.projectName || arch.projectName || "FullstackApp";
|
||||
const prdSummary = prd.summary || "";
|
||||
const contract = arch.contract || createContract(prd, { databaseSchema: arch.databaseSchema || [], domain: arch.domain });
|
||||
|
||||
// ── Dynamic imports of builder agents ──
|
||||
const fbMod = await import("./frontend-builder-agent.mjs");
|
||||
const bbMod = await import("./backend-builder-agent.mjs");
|
||||
|
||||
// ── Build frontend ──
|
||||
const frontend = fbMod.buildFrontend(prd, arch);
|
||||
if (frontend.error) return { error: "FRONTEND_BUILD_FAILED", message: frontend.message, files: {}, stats: {} };
|
||||
|
||||
// ── Build backend ──
|
||||
const backend = bbMod.buildBackend(prd, arch);
|
||||
if (backend.error) return { error: "BACKEND_BUILD_FAILED", message: backend.message, files: {}, stats: {} };
|
||||
|
||||
// ── Post-process for monorepo ──
|
||||
// Prefix frontend paths with src/ for Next.js src directory structure
|
||||
const frontendSrc = prefixSrc(frontend.files);
|
||||
const webFiles = postProcessFrontend(frontendSrc, projectName);
|
||||
const apiFiles = postProcessBackend(backend.files, projectName, contract);
|
||||
|
||||
// ── Shared packages ──
|
||||
const sharedTypesPkg = generateSharedTypesPackage(contract);
|
||||
const sharedConfigPkg = generateSharedConfig();
|
||||
|
||||
// ── Root files ──
|
||||
const rootFiles = generateRootFiles(projectName, prdSummary, contract);
|
||||
|
||||
// ── Scripts ──
|
||||
const scripts = generateScripts();
|
||||
|
||||
// ══ Assemble final file tree ══
|
||||
const files = {};
|
||||
|
||||
// apps/web/ (frontend)
|
||||
for (const [relPath, content] of Object.entries(webFiles)) {
|
||||
files[`apps/web/${relPath}`] = content;
|
||||
}
|
||||
|
||||
// apps/api/ (backend)
|
||||
for (const [relPath, content] of Object.entries(apiFiles)) {
|
||||
files[`apps/api/${relPath}`] = content;
|
||||
}
|
||||
|
||||
// packages/shared-types/
|
||||
for (const [relPath, content] of Object.entries(sharedTypesPkg)) {
|
||||
files[`packages/shared-types/${relPath}`] = content;
|
||||
}
|
||||
|
||||
// packages/shared-config/
|
||||
for (const [relPath, content] of Object.entries(sharedConfigPkg)) {
|
||||
files[`packages/shared-config/${relPath}`] = content;
|
||||
}
|
||||
|
||||
// Root files
|
||||
for (const [relPath, content] of Object.entries(rootFiles)) {
|
||||
files[relPath] = content;
|
||||
}
|
||||
|
||||
// Scripts
|
||||
for (const [relPath, content] of Object.entries(scripts)) {
|
||||
files[relPath] = content;
|
||||
}
|
||||
|
||||
// ══ Stats ══
|
||||
const allPaths = Object.keys(files);
|
||||
const webPaths = allPaths.filter(p => p.startsWith("apps/web/"));
|
||||
const apiPaths = allPaths.filter(p => p.startsWith("apps/api/"));
|
||||
const sharedPaths = allPaths.filter(p => p.startsWith("packages/"));
|
||||
const rootPaths = allPaths.filter(p => !p.includes("/"));
|
||||
|
||||
const stats = {
|
||||
totalFiles: allPaths.length,
|
||||
webFiles: webPaths.length,
|
||||
apiFiles: apiPaths.length,
|
||||
sharedFiles: sharedPaths.length,
|
||||
rootFiles: rootPaths.length,
|
||||
dbSchema: contract.entities.length,
|
||||
};
|
||||
|
||||
return { files, stats, error: null, message: null };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 9. File I/O
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
export function loadJSON(path) {
|
||||
try {
|
||||
if (!existsSync(path)) return { data: null, error: `File not found: ${path}` };
|
||||
return { data: JSON.parse(readFileSync(path, "utf-8")), error: null };
|
||||
} catch (e) {
|
||||
return { data: null, error: `Failed to load: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeFullstack(result, outputDir) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
for (const [relPath, content] of Object.entries(result.files)) {
|
||||
const fullPath = resolve(outputDir, relPath);
|
||||
mkdirSync(dirname(fullPath), { recursive: true });
|
||||
writeFileSync(fullPath, content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix frontend source files with src/ for Next.js src directory structure.
|
||||
* Frontend builder outputs flat paths (app/layout.tsx, types/index.ts),
|
||||
* but monorepo structure uses apps/web/src/ prefix.
|
||||
* Config files (package.json, tsconfig.json, next.config.*, tailwind.config.*,
|
||||
* postcss.config.*, .env*) stay at root level.
|
||||
*
|
||||
* @param {object} raw — Frontend builder output paths (e.g. "app/layout.tsx")
|
||||
* @returns {object} — Source paths prefixed, config paths kept at root
|
||||
*/
|
||||
function prefixSrc(raw) {
|
||||
const CONFIG_FILES = /^(package\.json|tsconfig\.json|next\.config\.[a-z]+|tailwind\.config\.[a-z]+|postcss\.config\.[a-z]+|\.[a-z-]+)$/;
|
||||
const result = {};
|
||||
for (const [relPath, content] of Object.entries(raw)) {
|
||||
if (relPath.startsWith("src/")) {
|
||||
// Already prefixed — keep as-is
|
||||
result[relPath] = content;
|
||||
} else if (CONFIG_FILES.test(relPath)) {
|
||||
// Config files — keep at root
|
||||
result[relPath] = content;
|
||||
} else {
|
||||
// Source code — prefix with src/
|
||||
result[`src/${relPath}`] = content;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 10. CLI Entry
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const opts = { prd: null, arch: null, inputText: null, output: null, verbose: false, help: false };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--prd" && args[i + 1]) opts.prd = args[++i];
|
||||
else if (args[i] === "--arch" && args[i + 1]) opts.arch = args[++i];
|
||||
else if (args[i] === "--input-text" && args[i + 1]) opts.inputText = args[++i];
|
||||
else if (args[i] === "--output" && args[i + 1]) opts.output = args[++i];
|
||||
else if (args[i] === "--verbose") opts.verbose = true;
|
||||
else if (args[i] === "--help" || args[i] === "-h") opts.help = true;
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs();
|
||||
|
||||
if (opts.help) {
|
||||
console.log(`
|
||||
Fullstack Composer Agent — SF-05
|
||||
|
||||
Usage:
|
||||
node scripts/fullstack-composer-agent.mjs --prd <path> --arch <path> [options]
|
||||
node scripts/fullstack-composer-agent.mjs --input-text "<需求>" [options]
|
||||
|
||||
Options:
|
||||
--prd <path> PRD JSON(SF-01 输出)
|
||||
--arch <path> Architecture JSON(SF-02 输出)
|
||||
--input-text <text> 直接传入需求
|
||||
--output <dir> 输出目录(default: fullstack/)
|
||||
--verbose 详细输出
|
||||
--help 显示帮助
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
let prd = null, arch = null;
|
||||
|
||||
if (opts.prd && opts.arch) {
|
||||
const prdRes = loadJSON(opts.prd);
|
||||
const archRes = loadJSON(opts.arch);
|
||||
if (prdRes.error) { console.error(prdRes.error); process.exit(1); }
|
||||
if (archRes.error) { console.error(archRes.error); process.exit(1); }
|
||||
prd = prdRes.data;
|
||||
arch = archRes.data;
|
||||
} else if (opts.inputText) {
|
||||
try {
|
||||
const sf01 = await import("./project-intake-agent.mjs");
|
||||
const sf02 = await import("./architecture-agent.mjs");
|
||||
prd = sf01.generatePRD(opts.inputText);
|
||||
if (prd.error) { console.error(`SF-01: ${prd.message}`); process.exit(1); }
|
||||
arch = sf02.generateArchitecture(prd);
|
||||
if (arch.error) { console.error(`SF-02: ${arch.message}`); process.exit(1); }
|
||||
} catch (e) {
|
||||
console.error(`Pipeline error: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!prd || !arch) {
|
||||
console.error("Error: --prd + --arch or --input-text is required. Use --help.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await composeFullstack(prd, arch);
|
||||
if (result.error) { console.error(result.message); process.exit(1); }
|
||||
|
||||
const outputDir = opts.output ? resolve(opts.output) : DEFAULT_OUTPUT;
|
||||
writeFullstack(result, outputDir);
|
||||
|
||||
if (opts.verbose) {
|
||||
console.error(`Project: ${prd.projectName}`);
|
||||
console.error(`Files: ${result.stats.totalFiles}`);
|
||||
console.error(`Web: ${result.stats.webFiles}, API: ${result.stats.apiFiles}, Shared: ${result.stats.sharedFiles}`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
projectName: prd.projectName,
|
||||
outputDir,
|
||||
stats: result.stats,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
if (process.argv[1] && (import.meta.url === `file://${process.argv[1]}` || import.meta.url.endsWith(process.argv[1]))) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
# ====================================================
|
||||
# PR-B Architecture Guard — Master Runner
|
||||
# OpenClaw Agent OS v2 — Architecture Stabilization
|
||||
# ====================================================
|
||||
# Runs all 6 architecture guards.
|
||||
# Exit 0 = all guards pass
|
||||
# Exit 1 = one or more guards failed
|
||||
# ====================================================
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
WARN_COUNT=0
|
||||
TOTAL=6
|
||||
|
||||
# Store results in temp file
|
||||
RESULT_FILE="$(mktemp)"
|
||||
trap "rm -f $RESULT_FILE" EXIT
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
|
||||
echo -e "${CYAN} OpenClaw Agent OS v2 — Architecture Guard${NC}"
|
||||
echo -e "${CYAN} PR-B: $(date '+%Y-%m-%d %H:%M:%S %Z')${NC}"
|
||||
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
|
||||
|
||||
run_guard() {
|
||||
local name="$1"
|
||||
local script="$2"
|
||||
local mode="$3" # "hard" or "soft"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}──────────────────────────────────────────${NC}"
|
||||
|
||||
if node "$script" 2>&1; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo "$name|PASS" >> "$RESULT_FILE"
|
||||
echo -e "${GREEN} ✓ $name passed${NC}"
|
||||
return 0
|
||||
else
|
||||
local exit_code=$?
|
||||
if [ "$mode" = "hard" ]; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo "$name|FAIL" >> "$RESULT_FILE"
|
||||
echo -e "${RED} ✗ $name FAILED (exit $exit_code)${NC}"
|
||||
return 1
|
||||
else
|
||||
WARN_COUNT=$((WARN_COUNT + 1))
|
||||
echo "$name|WARN" >> "$RESULT_FILE"
|
||||
echo -e "${YELLOW} ⚠ $name warning (exit $exit_code)${NC}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Run all 6 guards
|
||||
run_guard "Guard-1: Memory Backend" "scripts/guard-memory-backend.mjs" "hard"
|
||||
run_guard "Guard-2: Runtime Core" "scripts/guard-runtime-core.mjs" "hard"
|
||||
run_guard "Guard-3: Tool Path" "scripts/guard-tool-path.mjs" "soft"
|
||||
run_guard "Guard-4: MEMORY.md Write" "scripts/guard-memory-write.mjs" "hard"
|
||||
run_guard "Guard-5: Tool Trace" "scripts/guard-tool-trace.mjs" "soft"
|
||||
run_guard "Guard-6: Dreaming Phase" "scripts/guard-dreaming-phase.mjs" "hard"
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
|
||||
echo -e "${CYAN} Architecture Guard Summary${NC}"
|
||||
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
|
||||
while IFS='|' read -r name result; do
|
||||
case "$result" in
|
||||
PASS) echo -e " ${GREEN}[PASS]${NC} $name" ;;
|
||||
FAIL) echo -e " ${RED}[FAIL]${NC} $name" ;;
|
||||
WARN) echo -e " ${YELLOW}[WARN]${NC} $name" ;;
|
||||
esac
|
||||
done < "$RESULT_FILE"
|
||||
|
||||
echo ""
|
||||
echo -e " Total: $TOTAL | ${GREEN}Pass: $PASS_COUNT${NC} | ${RED}Fail: $FAIL_COUNT${NC} | ${YELLOW}Warn: $WARN_COUNT${NC}"
|
||||
|
||||
if [ $FAIL_COUNT -eq 0 ]; then
|
||||
echo ""
|
||||
echo -e "${GREEN} ✓ ALL ARCHITECTURE GUARDS PASSED${NC}"
|
||||
echo -e "${GREEN} System is within allowed architecture boundaries.${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo ""
|
||||
echo -e "${RED} ✗ ARCHITECTURE VIOLATIONS DETECTED${NC}"
|
||||
echo -e "${RED} Fix failures before proceeding.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Guard 6: Dreaming Phase Guard
|
||||
* Prevents dreaming phase proliferation beyond 3 phases.
|
||||
*
|
||||
* → Architecture Freeze v2 (PR-1): docs/architecture-freeze-v2.md
|
||||
* → Current: light, deep, rem | Legacy: rem → to be replaced by promote
|
||||
*
|
||||
* Currently allowed phases: light, deep, rem
|
||||
* After PR-5: will be reduced to collect, promote
|
||||
*
|
||||
* FAIL: new dreaming phase appears (count > 3)
|
||||
* PASS: phase count <= 3
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || "/opt/homebrew/lib/node_modules/openclaw";
|
||||
const EXTENSIONS_DIR = join(OPENCLAW_HOME, "dist", "extensions");
|
||||
|
||||
const PHASE_LIMIT = 3; // Current baseline
|
||||
const POST_PR5_LIMIT = 2; // After PR-5: collect + promote
|
||||
|
||||
function guardDreamingPhases() {
|
||||
const issues = [];
|
||||
let foundPhases = [];
|
||||
let source = "none";
|
||||
|
||||
// Check memory-core plugin config schema
|
||||
const memoryCorePath = join(EXTENSIONS_DIR, "memory-core", "openclaw.plugin.json");
|
||||
if (existsSync(memoryCorePath)) {
|
||||
try {
|
||||
const plugin = JSON.parse(readFileSync(memoryCorePath, "utf8"));
|
||||
const dreaming = plugin.configSchema?.properties?.dreaming;
|
||||
if (dreaming) {
|
||||
const phases = dreaming.properties?.phases?.properties;
|
||||
if (phases) {
|
||||
foundPhases = Object.keys(phases);
|
||||
source = "memory-core/openclaw.plugin.json → configSchema.properties.dreaming.properties.phases";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
issues.push({ level: "ERROR", msg: `Could not parse memory-core plugin.json: ${e.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Also check active-memory for dreaming references
|
||||
const activeMemoryPath = join(EXTENSIONS_DIR, "active-memory", "openclaw.plugin.json");
|
||||
if (existsSync(activeMemoryPath)) {
|
||||
try {
|
||||
const plugin = JSON.parse(readFileSync(activeMemoryPath, "utf8"));
|
||||
// Check if active-memory references phases
|
||||
const configKeys = Object.keys(plugin.configSchema?.properties || {});
|
||||
if (configKeys.includes("dreaming")) {
|
||||
// Active memory has dreaming config too
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("═══════════════════════════════════════");
|
||||
console.log(" Guard 6: Dreaming Phase Guard");
|
||||
console.log("═══════════════════════════════════════");
|
||||
console.log("");
|
||||
|
||||
if (foundPhases.length === 0) {
|
||||
console.log(" No dreaming phases found in plugin config schemas.");
|
||||
console.log(" (This may indicate a config schema location change.)");
|
||||
issues.push({ level: "WARN", msg: "Could not locate dreaming phases in config schema" });
|
||||
} else {
|
||||
console.log(` Source: ${source}`);
|
||||
console.log(` Dreaming phases: ${foundPhases.join(", ")}`);
|
||||
console.log(` Phase count: ${foundPhases.length}`);
|
||||
console.log(` Current limit: ${PHASE_LIMIT} (light, deep, rem)`);
|
||||
console.log(` Post-PR5 limit: ${POST_PR5_LIMIT} (collect, promote)`);
|
||||
|
||||
if (foundPhases.length > PHASE_LIMIT) {
|
||||
issues.push({
|
||||
level: "FAIL",
|
||||
msg: `Dreaming phase count ${foundPhases.length} exceeds limit ${PHASE_LIMIT}`
|
||||
});
|
||||
} else {
|
||||
console.log(`\n Phase details:`);
|
||||
for (const phase of foundPhases) {
|
||||
console.log(` • ${phase}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
for (const issue of issues) {
|
||||
console.log(` [${issue.level}] ${issue.msg}`);
|
||||
}
|
||||
|
||||
if (issues.filter(i => i.level === "FAIL").length === 0) {
|
||||
console.log(" ✓ PASS: dreaming phase count within allowed limit");
|
||||
console.log(` (After PR-5, limit will be reduced from ${PHASE_LIMIT} → ${POST_PR5_LIMIT})`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(" ✗ FAIL: dreaming phase count exceeded");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
guardDreamingPhases();
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Guard 1: Memory Backend Guard
|
||||
* Prevents proliferation beyond the allowed memory backends.
|
||||
*
|
||||
* → Architecture Freeze v2 (PR-1): docs/architecture-freeze-v2.md
|
||||
* → Core: memory-core | Legacy: memory-wiki
|
||||
*
|
||||
* Allowed plugins (kind="memory" or has memory tools):
|
||||
* - memory-core (primary — Core §2)
|
||||
* - active-memory (recall injector — Core §2)
|
||||
* - memory-wiki (legacy, allowed for now — Legacy §4)
|
||||
* - Builtin/QMD (internal engine, not a plugin)
|
||||
*
|
||||
* FAIL: any new memory plugin appears
|
||||
* PASS: within allowed list
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || "/opt/homebrew/lib/node_modules/openclaw";
|
||||
const EXTENSIONS_DIR = join(OPENCLAW_HOME, "dist", "extensions");
|
||||
|
||||
// Allowed memory plugin IDs
|
||||
const ALLOWED_MEMORY_PLUGINS = new Set([
|
||||
"memory-core",
|
||||
"active-memory",
|
||||
"memory-wiki",
|
||||
]);
|
||||
|
||||
// Plugin IDs that have memory contracts but are not "kind: memory"
|
||||
const KNOWN_MEMORY_RELATED = new Set([
|
||||
"memory-lancedb", // legacy, being deprecated
|
||||
]);
|
||||
|
||||
function guardMemoryBackends() {
|
||||
const issues = [];
|
||||
const found = [];
|
||||
|
||||
if (!existsSync(EXTENSIONS_DIR)) {
|
||||
issues.push({ level: "ERROR", msg: `Extensions dir not found: ${EXTENSIONS_DIR}` });
|
||||
return { pass: false, found, issues };
|
||||
}
|
||||
|
||||
const extDirs = readdirSync(EXTENSIONS_DIR, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name);
|
||||
|
||||
for (const extDir of extDirs) {
|
||||
const pluginJsonPath = join(EXTENSIONS_DIR, extDir, "openclaw.plugin.json");
|
||||
if (!existsSync(pluginJsonPath)) continue;
|
||||
|
||||
try {
|
||||
const plugin = JSON.parse(readFileSync(pluginJsonPath, "utf8"));
|
||||
const pluginId = plugin.id || extDir;
|
||||
const kind = plugin.kind || "";
|
||||
const contracts = plugin.contracts || {};
|
||||
const tools = contracts.tools || [];
|
||||
const memoryTools = tools.filter(t => t.startsWith("memory_") || t.startsWith("wiki_"));
|
||||
|
||||
const isMemoryPlugin =
|
||||
kind === "memory" ||
|
||||
tools.includes("memory_search") ||
|
||||
tools.includes("memory_get") ||
|
||||
memoryTools.length >= 2;
|
||||
|
||||
if (isMemoryPlugin) {
|
||||
found.push({ id: pluginId, kind, memoryTools });
|
||||
|
||||
if (!ALLOWED_MEMORY_PLUGINS.has(pluginId) && !KNOWN_MEMORY_RELATED.has(pluginId)) {
|
||||
issues.push({
|
||||
level: "FAIL",
|
||||
msg: `UNEXPECTED memory plugin: ${pluginId} (kind=${kind}, tools=${memoryTools.join(",")})`
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
issues.push({ level: "WARN", msg: `Could not parse ${pluginJsonPath}: ${e.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
const pass = issues.filter(i => i.level === "FAIL").length === 0;
|
||||
return { pass, found, issues };
|
||||
}
|
||||
|
||||
// Run
|
||||
const result = guardMemoryBackends();
|
||||
|
||||
console.log("");
|
||||
console.log("═══════════════════════════════════════");
|
||||
console.log(" Guard 1: Memory Backend Guard");
|
||||
console.log("═══════════════════════════════════════");
|
||||
console.log("");
|
||||
|
||||
if (result.found.length === 0) {
|
||||
console.log(" No memory plugins found.");
|
||||
} else {
|
||||
console.log(` Memory plugins found: ${result.found.length}`);
|
||||
for (const f of result.found) {
|
||||
const status = ALLOWED_MEMORY_PLUGINS.has(f.id) ? "✓" :
|
||||
KNOWN_MEMORY_RELATED.has(f.id) ? "⚠ (legacy)" : "✗ UNEXPECTED";
|
||||
console.log(` ${status} ${f.id} (kind="${f.kind}", tools: ${f.memoryTools.join(", ")})`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const issue of result.issues) {
|
||||
console.log(` [${issue.level}] ${issue.msg}`);
|
||||
}
|
||||
|
||||
if (result.pass) {
|
||||
console.log("");
|
||||
console.log(" ✓ PASS: memory backend count within allowed list");
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log("");
|
||||
console.log(" ✗ FAIL: unexpected memory backend(s) detected");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Guard 4: MEMORY.md Direct Write Guard
|
||||
* Prevents new code from writing directly to MEMORY.md.
|
||||
*
|
||||
* → Architecture Freeze v2 (PR-1): docs/architecture-freeze-v2.md
|
||||
* → Forbidden Addition §6.6: 禁止直接写 MEMORY.md
|
||||
*
|
||||
* Only allowed writers (whitelisted):
|
||||
* - session-memory hook (bundled/session-memory)
|
||||
* - memory-core dreaming (extensions/memory-core)
|
||||
* - memory-core manager (extensions/memory-core)
|
||||
* - openclaw CLI (memory promote/write)
|
||||
*
|
||||
* FAIL: new code path writes to MEMORY.md
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || "/opt/homebrew/lib/node_modules/openclaw";
|
||||
const DIST_DIR = join(OPENCLAW_HOME, "dist");
|
||||
const WORKSPACE = join(homedir(), ".openclaw", "workspace");
|
||||
|
||||
// Known/allowed MEMORY.md writers
|
||||
const ALLOWED_MEMORY_MD_WRITERS = new Set([
|
||||
"memory-core",
|
||||
"memory-wiki",
|
||||
"active-memory", // writes to prompt, not MEMORY.md directly
|
||||
]);
|
||||
|
||||
function guardMemoryDirectWrite() {
|
||||
const issues = [];
|
||||
const foundWriters = [];
|
||||
|
||||
// Search JS files in dist for MEMORY.md write references
|
||||
// We grep for patterns that write to MEMORY.md
|
||||
const searchPatterns = [
|
||||
"MEMORY.md",
|
||||
"'MEMORY.md'",
|
||||
'"MEMORY.md"',
|
||||
];
|
||||
|
||||
try {
|
||||
// Use grep to find references to MEMORY.md in dist JS files
|
||||
const grepResult = execSync(
|
||||
`grep -rl "MEMORY.md" "${DIST_DIR}" 2>/dev/null | grep -v ".d.ts$" | grep -v "node_modules" | grep -v ".json$" | head -50`,
|
||||
{ timeout: 30000, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }
|
||||
).trim();
|
||||
|
||||
if (grepResult) {
|
||||
const files = grepResult.split("\n").filter(Boolean);
|
||||
for (const file of files) {
|
||||
const relativePath = file.replace(DIST_DIR + "/", "");
|
||||
|
||||
// Determine which module this belongs to
|
||||
let module = "unknown";
|
||||
if (relativePath.includes("memory-core")) module = "memory-core";
|
||||
else if (relativePath.includes("memory-wiki")) module = "memory-wiki";
|
||||
else if (relativePath.includes("memory-lancedb")) module = "memory-lancedb";
|
||||
else if (relativePath.includes("active-memory")) module = "active-memory";
|
||||
else if (relativePath.includes("bundled/session-memory")) module = "bundled/session-memory";
|
||||
else if (relativePath.includes("bundle")) module = "bundled";
|
||||
else if (relativePath.includes("cli")) module = "cli";
|
||||
else module = relativePath.split("/")[0] || "unknown";
|
||||
|
||||
foundWriters.push({ file: relativePath, module });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// grep returns exit 1 if no matches found — that's OK
|
||||
if (e.status !== 1) {
|
||||
issues.push({ level: "WARN", msg: `grep error: ${e.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Group by module
|
||||
const moduleGroups = {};
|
||||
for (const w of foundWriters) {
|
||||
if (!moduleGroups[w.module]) moduleGroups[w.module] = [];
|
||||
moduleGroups[w.module].push(w.file);
|
||||
}
|
||||
|
||||
const knownModules = new Set(Object.keys(moduleGroups));
|
||||
|
||||
console.log("");
|
||||
console.log("═══════════════════════════════════════");
|
||||
console.log(" Guard 4: MEMORY.md Direct Write Guard");
|
||||
console.log("═══════════════════════════════════════");
|
||||
console.log("");
|
||||
|
||||
console.log(` Modules referencing MEMORY.md: ${knownModules.size}`);
|
||||
for (const [mod, files] of Object.entries(moduleGroups)) {
|
||||
const status = ALLOWED_MEMORY_MD_WRITERS.has(mod) ? "✓ allowed" : "⚠ unknown";
|
||||
console.log(` ${status} ${mod} (${files.length} files)`);
|
||||
if (files.length <= 3) {
|
||||
for (const f of files) {
|
||||
console.log(` - ${f}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unexpected writers
|
||||
const unexpectedModules = [...knownModules].filter(m => !ALLOWED_MEMORY_MD_WRITERS.has(m));
|
||||
|
||||
if (unexpectedModules.length > 0) {
|
||||
console.log("");
|
||||
for (const mod of unexpectedModules) {
|
||||
// some modules like "bundled" are expected to reference MEMORY.md for reading
|
||||
// Only flag as WARN if they might be writing
|
||||
issues.push({
|
||||
level: "WARN",
|
||||
msg: `Module "${mod}" references MEMORY.md — verify it's read-only`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
const failIssues = issues.filter(i => i.level === "FAIL");
|
||||
const warnIssues = issues.filter(i => i.level === "WARN");
|
||||
|
||||
if (failIssues.length > 0) {
|
||||
for (const issue of failIssues) {
|
||||
console.log(` [FAIL] ${issue.msg}`);
|
||||
}
|
||||
console.log(" ✗ FAIL: new direct MEMORY.md writer(s) detected");
|
||||
process.exit(1);
|
||||
} else {
|
||||
if (warnIssues.length > 0) {
|
||||
for (const issue of warnIssues) {
|
||||
console.log(` [WARN] ${issue.msg}`);
|
||||
}
|
||||
}
|
||||
console.log(" ✓ PASS: MEMORY.md direct write paths unchanged");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
guardMemoryDirectWrite();
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Guard 2: Agent Runtime Core Guard
|
||||
* Prevents proliferation beyond the 3 allowed agent runtimes.
|
||||
*
|
||||
* → Architecture Freeze v2 (PR-1): docs/architecture-freeze-v2.md
|
||||
* → Core: embedded-agent-runner, acp | Experimental: codex-supervisor
|
||||
*
|
||||
* Allowed agent runtimes (entry points):
|
||||
* - embedded-agent (built into OpenClaw — Core §2)
|
||||
* - codex (codex supervisor/app-server harness — Experimental §5)
|
||||
* - acp (ACP agent — Core §2)
|
||||
*
|
||||
* RUNTIME UTILITY FILES (e.g. auth-profiles.runtime.js) are NOT new runtimes.
|
||||
* Only AGENT ENTRY POINT modules count as runtime cores.
|
||||
*
|
||||
* FAIL: a 4th agent runtime entry point appears
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || "/opt/homebrew/lib/node_modules/openclaw";
|
||||
const DIST_DIR = join(OPENCLAW_HOME, "dist");
|
||||
|
||||
// Known agent runtime core entry points
|
||||
const KNOWN_RUNTIME_ENTRIES = new Set([
|
||||
"embedded-agent", // Embedded agent runner
|
||||
"codex-supervisor", // Codex app-server harness
|
||||
"acp", // ACP agent directory
|
||||
]);
|
||||
|
||||
// Known runtime UTILITY files (not new runtimes)
|
||||
const RUNTIME_UTILITY_PREFIXES = [
|
||||
"auth-profiles",
|
||||
"models-config",
|
||||
"model-catalog",
|
||||
"agent-bundle-mcp",
|
||||
"runtime-",
|
||||
"runtime.",
|
||||
];
|
||||
|
||||
function isRuntimeUtility(filename) {
|
||||
const name = filename.replace(/\.js$/, "").replace(/\.d\.ts$/, "");
|
||||
return RUNTIME_UTILITY_PREFIXES.some(p => name.startsWith(p) || name.includes(".runtime"));
|
||||
}
|
||||
|
||||
function guardRuntimeCores() {
|
||||
const issues = [];
|
||||
const foundEntries = [];
|
||||
const utilities = [];
|
||||
|
||||
if (!existsSync(DIST_DIR)) {
|
||||
issues.push({ level: "ERROR", msg: `Dist dir not found: ${DIST_DIR}` });
|
||||
return { pass: false, foundEntries, issues };
|
||||
}
|
||||
|
||||
// 1. Check extensions for agent runtime plugins
|
||||
const extensionsDir = join(DIST_DIR, "extensions");
|
||||
if (existsSync(extensionsDir)) {
|
||||
const extDirs = readdirSync(extensionsDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory());
|
||||
|
||||
for (const extDir of extDirs) {
|
||||
const pluginJsonPath = join(extensionsDir, extDir.name, "openclaw.plugin.json");
|
||||
if (!existsSync(pluginJsonPath)) continue;
|
||||
|
||||
try {
|
||||
const plugin = JSON.parse(readFileSync(pluginJsonPath, "utf8"));
|
||||
const id = plugin.id || extDir.name;
|
||||
|
||||
// Check if this plugin provides agent runtime capabilities
|
||||
const contracts = plugin.contracts || {};
|
||||
const tools = contracts.tools || [];
|
||||
const hasRuntimeTools = tools.some(t =>
|
||||
t.includes("codex_session") ||
|
||||
t.includes("agent_") ||
|
||||
t.includes("acp_")
|
||||
);
|
||||
|
||||
if (hasRuntimeTools) {
|
||||
foundEntries.push({ name: id, source: "plugin.json", type: "extension" });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check for embedded-agent directory
|
||||
const agentsDir = join(DIST_DIR, "agents");
|
||||
if (existsSync(agentsDir)) {
|
||||
const agentSubDirs = readdirSync(agentsDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && d.name.includes("agent"));
|
||||
for (const d of agentSubDirs) {
|
||||
foundEntries.push({ name: d.name, source: `dist/agents/${d.name}`, type: "core" });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check for ACP directory
|
||||
const acpDir = join(DIST_DIR, "acp");
|
||||
if (existsSync(acpDir)) {
|
||||
foundEntries.push({ name: "acp", source: "dist/acp/", type: "core" });
|
||||
}
|
||||
|
||||
// 4. Categorize: entries vs utilities
|
||||
const agentRuntimeEntries = foundEntries.filter(e => {
|
||||
const name = e.name;
|
||||
return KNOWN_RUNTIME_ENTRIES.has(name) ||
|
||||
name.includes("embedded-agent") ||
|
||||
name.includes("codex") ||
|
||||
name === "acp";
|
||||
});
|
||||
|
||||
const unknownEntries = foundEntries.filter(e => !agentRuntimeEntries.includes(e));
|
||||
|
||||
console.log(` Agent runtime entries found: ${agentRuntimeEntries.length}`);
|
||||
console.log(` Allowed maximum: 3 (embedded, codex, acp)`);
|
||||
|
||||
for (const e of agentRuntimeEntries) {
|
||||
const known = KNOWN_RUNTIME_ENTRIES.has(e.name) ||
|
||||
e.name.includes("embedded-agent") ||
|
||||
e.name.includes("codex") ||
|
||||
e.name === "acp";
|
||||
const status = known ? "✓" : "?";
|
||||
console.log(` ${status} ${e.name} (${e.source}, type=${e.type})`);
|
||||
}
|
||||
|
||||
// Check for unknown entries
|
||||
for (const e of unknownEntries) {
|
||||
if (isRuntimeUtility(e.name)) {
|
||||
utilities.push(e);
|
||||
console.log(` ○ ${e.name} (utility — not a runtime core)`);
|
||||
} else {
|
||||
issues.push({
|
||||
level: "FAIL",
|
||||
msg: `UNEXPECTED agent runtime entry: ${e.name} (${e.source})`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Count distinct runtime types
|
||||
const distinctEntries = new Set(
|
||||
agentRuntimeEntries.map(e => {
|
||||
if (e.name.includes("embedded-agent")) return "embedded";
|
||||
if (e.name.includes("codex")) return "codex";
|
||||
if (e.name === "acp") return "acp";
|
||||
return e.name;
|
||||
})
|
||||
);
|
||||
|
||||
if (distinctEntries.size > 3) {
|
||||
issues.push({
|
||||
level: "FAIL",
|
||||
msg: `Runtime entry count ${distinctEntries.size} > allowed maximum 3`
|
||||
});
|
||||
}
|
||||
|
||||
const pass = issues.filter(i => i.level === "FAIL").length === 0;
|
||||
|
||||
if (utilities.length > 0) {
|
||||
console.log(` Runtime utility files: ${utilities.length} (not runtime cores)`);
|
||||
}
|
||||
|
||||
return { pass, foundEntries: agentRuntimeEntries, issues, utilities };
|
||||
}
|
||||
|
||||
// Run
|
||||
const result = guardRuntimeCores();
|
||||
|
||||
console.log("");
|
||||
console.log("═══════════════════════════════════════");
|
||||
console.log(" Guard 2: Agent Runtime Core Guard");
|
||||
console.log("═══════════════════════════════════════");
|
||||
console.log("");
|
||||
|
||||
for (const issue of result.issues) {
|
||||
console.log(` [${issue.level}] ${issue.msg}`);
|
||||
}
|
||||
|
||||
if (result.pass) {
|
||||
console.log("");
|
||||
console.log(" ✓ PASS: no unexpected agent runtime core detected");
|
||||
console.log(` Allowed limit: 3 (embedded, codex, acp)`);
|
||||
console.log(` Found: ${result.foundEntries.length} runtime entries`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log("");
|
||||
console.log(" ✗ FAIL: unexpected runtime core(s) detected");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Guard 3: Tool Path Guard
|
||||
* Records current tool call entry points. Soft warning — no hard fail yet.
|
||||
*
|
||||
* → Architecture Freeze v2 (PR-1): docs/architecture-freeze-v2.md
|
||||
* → Adapter §3.2: Tool Adapters
|
||||
*
|
||||
* ToolCore not yet implemented — this guard inventories the current state
|
||||
* and will FAIL only if a NEW unknown tool path appears.
|
||||
*
|
||||
* Known tool paths:
|
||||
* - native tool (dist/commands/*)
|
||||
* - MCP tool (dist/mcp/*)
|
||||
* - plugin tool (dist/extensions plugin.json contracts.tools)
|
||||
* - codex tool (dist/extensions/codex-supervisor/*)
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || "/opt/homebrew/lib/node_modules/openclaw";
|
||||
const DIST_DIR = join(OPENCLAW_HOME, "dist");
|
||||
const EXTENSIONS_DIR = join(DIST_DIR, "extensions");
|
||||
|
||||
function guardToolPaths() {
|
||||
const issues = [];
|
||||
const inventory = { native: [], mcp: [], plugin: [], codex: [], unknown: [] };
|
||||
const knownToolPaths = new Set([
|
||||
"native", "mcp", "plugin", "codex"
|
||||
]);
|
||||
|
||||
// 1. Native tools — look for tool registrations in commands
|
||||
const commandsDir = join(DIST_DIR, "commands");
|
||||
if (existsSync(commandsDir)) {
|
||||
try {
|
||||
const entries = readdirSync(commandsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
inventory.native.push(`commands/${entry.name}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
issues.push({ level: "WARN", msg: `Could not scan commands dir: ${e.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. MCP tools
|
||||
const mcpDir = join(DIST_DIR, "mcp");
|
||||
if (existsSync(mcpDir)) {
|
||||
try {
|
||||
const files = readdirSync(mcpDir).filter(f => f.endsWith(".js"));
|
||||
inventory.mcp.push(...files.map(f => `mcp/${f}`));
|
||||
} catch (e) {
|
||||
issues.push({ level: "WARN", msg: `Could not scan mcp dir: ${e.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Plugin tools (from openclaw.plugin.json contracts)
|
||||
if (existsSync(EXTENSIONS_DIR)) {
|
||||
try {
|
||||
const extDirs = readdirSync(EXTENSIONS_DIR, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory());
|
||||
|
||||
for (const extDir of extDirs) {
|
||||
const pluginJsonPath = join(EXTENSIONS_DIR, extDir.name, "openclaw.plugin.json");
|
||||
if (!existsSync(pluginJsonPath)) continue;
|
||||
|
||||
try {
|
||||
const plugin = JSON.parse(readFileSync(pluginJsonPath, "utf8"));
|
||||
const tools = plugin.contracts?.tools || [];
|
||||
|
||||
if (tools.length > 0) {
|
||||
// Categorize
|
||||
if (extDir.name.includes("codex") || extDir.name.includes("supervisor")) {
|
||||
inventory.codex.push(`${extDir.name}: [${tools.join(", ")}]`);
|
||||
} else {
|
||||
inventory.plugin.push(`${extDir.name}: [${tools.join(", ")}]`);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
issues.push({ level: "WARN", msg: `Could not scan extensions: ${e.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check for unknown tool paths in key files
|
||||
const keyFiles = readdirSync(DIST_DIR).filter(f =>
|
||||
f.endsWith(".js") && (f.includes("tool") || f.includes("command") || f.includes("mcp"))
|
||||
);
|
||||
for (const f of keyFiles) {
|
||||
const name = f.replace(/\.js$/, "").replace(/-[A-Za-z0-9]{8}$/, "");
|
||||
// Categorize based on naming
|
||||
if (name.includes("mcp") || name.includes("codex-mcp")) {
|
||||
if (!inventory.mcp.some(t => t.includes(name.substring(0, 10)))) {
|
||||
inventory.mcp.push(`dist/${f}`);
|
||||
}
|
||||
} else if (name.includes("tool")) {
|
||||
if (!inventory.native.some(t => t.includes(name.substring(0, 10)))) {
|
||||
inventory.native.push(`dist/${f}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report
|
||||
console.log("");
|
||||
console.log("═══════════════════════════════════════");
|
||||
console.log(" Guard 3: Tool Path Guard");
|
||||
console.log("═══════════════════════════════════════");
|
||||
console.log("");
|
||||
|
||||
console.log(" Tool Path Inventory:");
|
||||
console.log(` Native tools: ${inventory.native.length} entries`);
|
||||
console.log(` MCP tools: ${inventory.mcp.length} entries`);
|
||||
console.log(` Plugin tools: ${inventory.plugin.length} plugins`);
|
||||
console.log(` Codex tools: ${inventory.codex.length} plugins`);
|
||||
|
||||
if (inventory.plugin.length > 0) {
|
||||
console.log("\n Plugin tool contracts:");
|
||||
for (const p of inventory.plugin) {
|
||||
console.log(` • ${p}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (inventory.codex.length > 0) {
|
||||
console.log("\n Codex tool contracts:");
|
||||
for (const p of inventory.codex) {
|
||||
console.log(` • ${p}`);
|
||||
}
|
||||
}
|
||||
|
||||
// This guard is WARN-only for now (ToolCore not yet implemented)
|
||||
console.log("");
|
||||
console.log(" ⚠ WARN: ToolCore not yet enforced");
|
||||
console.log(" This guard currently inventories tool paths.");
|
||||
console.log(" After PR-8/PR-9 (ToolCore), it will enforce all tools go through ToolExecutor.");
|
||||
|
||||
// No hard fail — informational only at this stage
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
guardToolPaths();
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Guard 5: Tool Trace Guard
|
||||
* Inventories tool call sites and checks for trace/log coverage.
|
||||
*
|
||||
* → Architecture Freeze v2 (PR-1): docs/architecture-freeze-v2.md
|
||||
* → Future: PR-10 Trace Model enforcement
|
||||
*
|
||||
* SOFT GUARD — Trace model not yet implemented (PR-10).
|
||||
* Currently inventories tool call entry points and reports gaps.
|
||||
* No hard failure at this stage.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || "/opt/homebrew/lib/node_modules/openclaw";
|
||||
const DIST_DIR = join(OPENCLAW_HOME, "dist");
|
||||
|
||||
function guardToolTrace() {
|
||||
const issues = [];
|
||||
const toolSites = [];
|
||||
let totalToolCallSites = 0;
|
||||
let sitesWithTrace = 0;
|
||||
|
||||
// Search for tool call patterns in dist
|
||||
const searchTerms = [
|
||||
"tool_call",
|
||||
"toolCall",
|
||||
"tool.call",
|
||||
"tool_exec",
|
||||
"toolExec",
|
||||
"invokeTool",
|
||||
"runTool",
|
||||
];
|
||||
|
||||
try {
|
||||
const grepResult = execSync(
|
||||
`grep -rln -E "(tool_call|toolCall|tool\\.call|tool_exec|toolExec|executeTool|invokeTool|runTool)" "${DIST_DIR}" 2>/dev/null | grep -v ".d.ts$" | grep -v "node_modules" | grep ".js$" | head -30`,
|
||||
{ timeout: 30000, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }
|
||||
).trim();
|
||||
|
||||
if (grepResult) {
|
||||
const files = grepResult.split("\n").filter(Boolean);
|
||||
totalToolCallSites = files.length;
|
||||
|
||||
for (const file of files) {
|
||||
const relativePath = file.replace(DIST_DIR + "/", "");
|
||||
|
||||
// Check if this file has trace/log instrumentation
|
||||
try {
|
||||
const content = readFileSync(file, "utf8");
|
||||
const hasTrace =
|
||||
content.includes("trace") ||
|
||||
content.includes("Trace") ||
|
||||
content.includes("span") ||
|
||||
content.includes("log") ||
|
||||
content.includes("event") ||
|
||||
content.includes("audit") ||
|
||||
content.includes("diagnostics");
|
||||
|
||||
if (hasTrace) sitesWithTrace++;
|
||||
|
||||
toolSites.push({
|
||||
file: relativePath,
|
||||
hasTrace,
|
||||
module: relativePath.split("/")[0] || "unknown",
|
||||
});
|
||||
} catch {
|
||||
toolSites.push({
|
||||
file: relativePath,
|
||||
hasTrace: false,
|
||||
module: relativePath.split("/")[0] || "unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.status !== 1) {
|
||||
console.log(` Warning: grep failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sitesWithoutTrace = toolSites.filter(s => !s.hasTrace);
|
||||
|
||||
console.log("");
|
||||
console.log("═══════════════════════════════════════");
|
||||
console.log(" Guard 5: Tool Trace Guard");
|
||||
console.log("═══════════════════════════════════════");
|
||||
console.log("");
|
||||
|
||||
console.log(` Tool call entry points found: ${totalToolCallSites}`);
|
||||
console.log(` Sites with trace/log: ${sitesWithTrace}`);
|
||||
console.log(` Sites without trace: ${sitesWithoutTrace.length}`);
|
||||
|
||||
// Group by module
|
||||
const moduleSummary = {};
|
||||
for (const s of toolSites) {
|
||||
if (!moduleSummary[s.module]) {
|
||||
moduleSummary[s.module] = { total: 0, traced: 0 };
|
||||
}
|
||||
moduleSummary[s.module].total++;
|
||||
if (s.hasTrace) moduleSummary[s.module].traced++;
|
||||
}
|
||||
|
||||
if (Object.keys(moduleSummary).length > 0) {
|
||||
console.log("\n Trace coverage by module:");
|
||||
for (const [mod, stats] of Object.entries(moduleSummary)) {
|
||||
const pct = stats.total > 0 ? Math.round(stats.traced / stats.total * 100) : 0;
|
||||
const bar = "█".repeat(Math.round(pct / 10)) + "░".repeat(10 - Math.round(pct / 10));
|
||||
console.log(` ${bar} ${mod}: ${stats.traced}/${stats.total} (${pct}%)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (sitesWithoutTrace.length > 0 && sitesWithoutTrace.length <= 10) {
|
||||
console.log("\n Sites without trace:");
|
||||
for (const s of sitesWithoutTrace) {
|
||||
console.log(` • ${s.file}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(" ⚠ WARN: Trace model not yet enforced");
|
||||
console.log(` Current coverage: ${sitesWithTrace}/${totalToolCallSites} sites have tracing`);
|
||||
console.log(" After PR-10 (Trace Model), all tool calls will require trace spans.");
|
||||
|
||||
// No hard fail — soft guard
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
guardToolTrace();
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# workspace 初始化脚本
|
||||
# 用途:新环境部署后一键还原工作状态
|
||||
# 用法:bash scripts/init-workspace.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "🔧 初始化工作空间..."
|
||||
|
||||
# 检查依赖
|
||||
for cmd in git node npm curl; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
echo "❌ 缺少依赖: $cmd"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "✅ 所有依赖已就绪"
|
||||
|
||||
# 检查 Git 远程
|
||||
cd "$(dirname "$0")/.."
|
||||
if ! git remote -v &>/dev/null; then
|
||||
echo "⚠️ 没有配置远程仓库,跳过"
|
||||
else
|
||||
echo "✅ Git 远程已配置"
|
||||
fi
|
||||
|
||||
# 检查浏览器
|
||||
if command -v openclaw &>/dev/null; then
|
||||
if openclaw browser doctor 2>&1 | grep -q "not running"; then
|
||||
echo "🦞 浏览器未启动,尝试启动..."
|
||||
openclaw browser start 2>/dev/null || echo "⚠️ 浏览器启动失败,可手动运行 openclaw browser start"
|
||||
else
|
||||
echo "✅ 浏览器工具已就绪"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ 任务流程脚本: scripts/task-flow.sh"
|
||||
echo "✅ Skill 提取脚本: scripts/extract-skill.sh"
|
||||
echo ""
|
||||
echo "✨ 工作空间就绪"
|
||||
echo " 目录: $(pwd)"
|
||||
echo " 日期: $(date '+%Y-%m-%d %H:%M')"
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* PR-C Deprecation Warning Utility
|
||||
* Provides `warnOnce(key, message)` — same key won't print twice.
|
||||
* Used by deprecation checks across all modules.
|
||||
*/
|
||||
|
||||
const _warned = new Set();
|
||||
|
||||
/**
|
||||
* Emit a deprecation warning once per process lifetime.
|
||||
* @param {string} key - unique key for this warning (e.g. "active-memory.blocking")
|
||||
* @param {string} message - human-readable message
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.force] - if true, emit even if already warned
|
||||
* @returns {boolean} true if warning was emitted, false if skipped (duplicate)
|
||||
*/
|
||||
export function warnOnce(key, message, opts = {}) {
|
||||
if (!opts.force && _warned.has(key)) {
|
||||
return false;
|
||||
}
|
||||
_warned.add(key);
|
||||
console.warn(`[DEPRECATED][OpenClaw v2] ${key}: ${message}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all warned keys (for testing).
|
||||
*/
|
||||
export function resetWarnings() {
|
||||
_warned.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of unique warnings emitted so far.
|
||||
*/
|
||||
export function getWarningCount() {
|
||||
return _warned.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all warned keys.
|
||||
*/
|
||||
export function getWarnedKeys() {
|
||||
return [..._warned];
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit multiple deprecation warnings with deduplication.
|
||||
* @param {Array<{key: string, message: string}>} warnings
|
||||
*/
|
||||
export function warnMany(warnings) {
|
||||
const emitted = [];
|
||||
for (const w of warnings) {
|
||||
if (warnOnce(w.key, w.message)) {
|
||||
emitted.push(w);
|
||||
}
|
||||
}
|
||||
return emitted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a deprecation check function and collect warnings.
|
||||
* @param {string} checkName - name of the check
|
||||
* @param {() => Array<{key:string, message:string}>} fn - check function
|
||||
* @returns {{ name: string, warnings: Array<{key:string, message:string}> }}
|
||||
*/
|
||||
export function runDeprecationCheck(checkName, fn) {
|
||||
try {
|
||||
const warnings = fn();
|
||||
return { name: checkName, warnings };
|
||||
} catch (e) {
|
||||
return { name: checkName, warnings: [{ key: checkName, message: `Check failed: ${e.message}` }] };
|
||||
}
|
||||
}
|
||||
Executable
+196
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env bash
|
||||
# memory-auto.sh — 记忆自动触发器(Phase 4)
|
||||
#
|
||||
# 让记忆系统"自己动起来":
|
||||
# 1. 会话开始时自动加载 Core Memory
|
||||
# 2. 对话中自动判断是否需要记忆操作
|
||||
# 3. 会话结束时自动 flush
|
||||
# 4. 定期自动维护
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/memory-auto.sh on-start # 会话开始时调用
|
||||
# bash scripts/memory-auto.sh on-message # 收到消息时调用(传入消息)
|
||||
# bash scripts/memory-auto.sh on-end # 会话结束时调用
|
||||
# bash scripts/memory-auto.sh on-idle # 空闲时调用(维护)
|
||||
# bash scripts/memory-auto.sh status # 查看自动触发状态
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
MEMORY_DIR="$WORKSPACE/memory"
|
||||
CORE_DIR="$MEMORY_DIR/core"
|
||||
LOG="$MEMORY_DIR/.memory-auto.log"
|
||||
|
||||
# 加载 API Key
|
||||
if [ -z "${DEEPSEEK_API_KEY:-}" ] && [ -f ~/.zshrc ]; then
|
||||
eval "$(grep '^export DEEPSEEK_API_KEY=' ~/.zshrc 2>/dev/null || true)"
|
||||
fi
|
||||
export DEEPSEEK_API_KEY
|
||||
|
||||
log() {
|
||||
echo "$(date '+%Y-%m-%d %H:%M') | $1" >> "$LOG"
|
||||
}
|
||||
|
||||
# ── 1. 会话开始 ──────────────────────────────────────
|
||||
|
||||
on_start() {
|
||||
echo "🚀 记忆系统: 会话开始"
|
||||
echo ""
|
||||
|
||||
# 加载 Core Memory
|
||||
echo "🧠 加载 Core Memory Blocks:"
|
||||
for f in "$CORE_DIR"/*.md; do
|
||||
[ -f "$f" ] || continue
|
||||
local name=$(basename "$f" .md)
|
||||
local size=$(wc -c < "$f" | tr -d ' ')
|
||||
echo " ✅ $name ($size bytes)"
|
||||
done
|
||||
echo ""
|
||||
|
||||
# 检查今天是否已有 daily
|
||||
local today="$MEMORY_DIR/daily/$(date +%Y-%m-%d).md"
|
||||
if [ ! -f "$today" ]; then
|
||||
echo "📅 创建今天的 daily: $(date +%Y-%m-%d)"
|
||||
echo "# $(date +%Y-%m-%d)" > "$today"
|
||||
echo "" >> "$today"
|
||||
fi
|
||||
|
||||
# 检查 open-loops
|
||||
if [ -f "$MEMORY_DIR/registers/open-loops.md" ]; then
|
||||
open=$(grep -c '^\- \[ \]' "$MEMORY_DIR/registers/open-loops.md" 2>/dev/null || true)
|
||||
open=${open:-0}
|
||||
[ "$open" -gt 0 ] && echo "📋 有 $open 个未完成的待办"
|
||||
fi
|
||||
|
||||
log "session_start"
|
||||
echo ""
|
||||
echo "✅ 记忆系统就绪"
|
||||
}
|
||||
|
||||
# ── 2. 消息处理 ──────────────────────────────────────
|
||||
|
||||
on_message() {
|
||||
local message="${1:-}"
|
||||
[ -z "$message" ] && return
|
||||
|
||||
# 快速判断:这条消息是否值得记忆操作
|
||||
local should_process=false
|
||||
|
||||
# 长消息可能包含重要信息
|
||||
[ ${#message} -gt 50 ] && should_process=true
|
||||
|
||||
# 包含关键词
|
||||
if echo "$message" | grep -qE '(记住|忘掉|偏好|纠正|决定|配置|部署|修复)'; then
|
||||
should_process=true
|
||||
fi
|
||||
|
||||
# 包含问句(可能需要搜索)
|
||||
if echo "$message" | grep -qE '(记得|之前|上次|以前|什么|哪个|怎么|为什么)'; then
|
||||
should_process=true
|
||||
fi
|
||||
|
||||
$should_process || return
|
||||
|
||||
# 静默调用 Write Gate(后台)
|
||||
if [ -f "$WORKSPACE/scripts/memory-write-gate.sh" ]; then
|
||||
echo "$message" | bash "$WORKSPACE/scripts/memory-write-gate.sh" > /dev/null 2>&1 &
|
||||
fi
|
||||
|
||||
log "message_processed: ${message:0:50}"
|
||||
}
|
||||
|
||||
# ── 3. 会话结束 ──────────────────────────────────────
|
||||
|
||||
on_end() {
|
||||
echo "💾 记忆系统: 会话结束"
|
||||
echo ""
|
||||
|
||||
# 触发 session-compact pre-compact
|
||||
if [ -f "$WORKSPACE/scripts/session-compact.sh" ]; then
|
||||
echo "📦 执行会话压缩..."
|
||||
bash "$WORKSPACE/scripts/session-compact.sh" pre-compact 2>&1 | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
# 检查是否有未保存的记忆
|
||||
local today="$MEMORY_DIR/daily/$(date +%Y-%m-%d).md"
|
||||
if [ -f "$today" ]; then
|
||||
local today_size=$(wc -c < "$today" | tr -d ' ')
|
||||
echo " 今天的 daily: $today_size bytes"
|
||||
fi
|
||||
|
||||
log "session_end"
|
||||
echo ""
|
||||
echo "✅ 会话记忆已保存"
|
||||
}
|
||||
|
||||
# ── 4. 空闲维护 ──────────────────────────────────────
|
||||
|
||||
on_idle() {
|
||||
echo "🔧 记忆系统: 空闲维护"
|
||||
echo ""
|
||||
|
||||
# 1. 实体提取
|
||||
if [ -f "$WORKSPACE/scripts/memory-entity.sh" ]; then
|
||||
echo "🧬 实体提取..."
|
||||
bash "$WORKSPACE/scripts/memory-entity.sh" extract 2>&1 | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
# 2. 健康检查
|
||||
if [ -f "$WORKSPACE/scripts/memory-health.sh" ]; then
|
||||
echo ""
|
||||
echo "🏥 健康检查..."
|
||||
bash "$WORKSPACE/scripts/memory-health.sh" score 2>&1 | tail -5 | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
log "idle_maintenance"
|
||||
echo ""
|
||||
echo "✅ 维护完成"
|
||||
}
|
||||
|
||||
# ── 5. 状态查看 ──────────────────────────────────────
|
||||
|
||||
status() {
|
||||
echo "📊 记忆自动触发状态"
|
||||
echo ""
|
||||
|
||||
# Core Memory 状态
|
||||
echo "🧠 Core Memory:"
|
||||
for f in "$CORE_DIR"/*.md; do
|
||||
[ -f "$f" ] || continue
|
||||
local name=$(basename "$f" .md)
|
||||
local mtime=$(stat -f "%Sm" -t "%Y-%m-%d %H:%M" "$f" 2>/dev/null || echo "unknown")
|
||||
local size=$(wc -c < "$f" | tr -d ' ')
|
||||
echo " $name: $size bytes ($mtime)"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "📋 日志 (最近 5 条):"
|
||||
[ -f "$LOG" ] && tail -5 "$LOG" || echo " (无)"
|
||||
|
||||
echo ""
|
||||
echo "🔧 可用脚本:"
|
||||
for script in write-gate recall entity compressor llm-router behavior health search; do
|
||||
local path="$WORKSPACE/scripts/memory-${script}.sh"
|
||||
[ -f "$path" ] && echo " ✅ memory-${script}.sh" || echo " ❌ memory-${script}.sh"
|
||||
done
|
||||
}
|
||||
|
||||
# ── 入口 ─────────────────────────────────────────────
|
||||
|
||||
case "${1:-help}" in
|
||||
on-start) on_start ;;
|
||||
on-message) on_message "${2:-}" ;;
|
||||
on-end) on_end ;;
|
||||
on-idle) on_idle ;;
|
||||
status) status ;;
|
||||
help|*)
|
||||
echo "用法: bash scripts/memory-auto.sh <命令>"
|
||||
echo ""
|
||||
echo "命令:"
|
||||
echo " on-start 会话开始时调用(加载 Core Memory)"
|
||||
echo " on-message 收到消息时调用(传入消息内容)"
|
||||
echo " on-end 会话结束时调用(flush + 压缩)"
|
||||
echo " on-idle 空闲时调用(维护)"
|
||||
echo " status 查看状态"
|
||||
;;
|
||||
esac
|
||||
Executable
+210
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env bash
|
||||
# memory-sync — 记忆同步与维护工具
|
||||
#
|
||||
# 从 Hermes Agent MemoryManager + MemoryStore 模式内化
|
||||
# 管理 memory/index.md -> 键值对索引
|
||||
# 管理 memory/projects/ -> 项目级记忆
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/memory-sync.sh index # 重建记忆索引
|
||||
# bash scripts/memory-sync.sh check # 检查记忆健康状况
|
||||
# bash scripts/memory-sync.sh prefetch <key> # 根据关键词召回相关记忆
|
||||
# bash scripts/memory-sync.sh backup # 备份所有记忆文件
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
MEMORY_DIR="$WORKSPACE/memory"
|
||||
PROJECTS_DIR="$MEMORY_DIR/projects"
|
||||
INDEX_FILE="$MEMORY_DIR/index.md"
|
||||
|
||||
# ── 颜色 ──
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
case "${1:-}" in
|
||||
index)
|
||||
echo "🔨 重建记忆索引..."
|
||||
|
||||
# 清空并重建索引
|
||||
cat > "$INDEX_FILE" << 'EOF'
|
||||
# 记忆索引
|
||||
|
||||
> 自动生成,供 Fast-Path 快速查找。
|
||||
> 格式:`关键词 | 所在文件 | 摘要`
|
||||
|
||||
EOF
|
||||
|
||||
# 索引 MEMORY.md 中的关键段落
|
||||
echo "" >> "$INDEX_FILE"
|
||||
echo "## MEMORY.md" >> "$INDEX_FILE"
|
||||
|
||||
# 提取各章节标题
|
||||
while IFS= read -r line; do
|
||||
if echo "$line" | grep -qE '^### '; then
|
||||
title=$(echo "$line" | sed 's/^### //')
|
||||
echo "- $title | MEMORY.md | 章节" >> "$INDEX_FILE"
|
||||
fi
|
||||
done < "$WORKSPACE/MEMORY.md"
|
||||
|
||||
# 索引项目记忆
|
||||
echo "" >> "$INDEX_FILE"
|
||||
echo "## 项目记忆" >> "$INDEX_FILE"
|
||||
for f in "$PROJECTS_DIR"/*.md; do
|
||||
[ -f "$f" ] || continue
|
||||
name=$(basename "$f" .md)
|
||||
echo "- $name | memory/projects/$name.md | 项目记忆" >> "$INDEX_FILE"
|
||||
# 提取项目中的关键标签
|
||||
while IFS= read -r line; do
|
||||
if echo "$line" | grep -qE '^\| .+ \| .+ \|'; then
|
||||
echo " - $(echo "$line" | awk -F'|' '{print $2}' | xargs)" >> "$INDEX_FILE"
|
||||
fi
|
||||
done < "$f"
|
||||
done
|
||||
|
||||
# 索引 daily 日志
|
||||
echo "" >> "$INDEX_FILE"
|
||||
echo "## 日常日志" >> "$INDEX_FILE"
|
||||
for f in "$MEMORY_DIR/daily"/*.md; do
|
||||
[ -f "$f" ] || continue
|
||||
name=$(basename "$f" .md)
|
||||
# 读取第一行非空标题
|
||||
title=$(head -3 "$f" | grep -E '^# ' | sed 's/^# //' || echo "$name")
|
||||
echo "- $title | memory/daily/$name" >> "$INDEX_FILE"
|
||||
done
|
||||
|
||||
echo -e "${GREEN}✅ 索引重建完成:$INDEX_FILE${NC}"
|
||||
wc -l < "$INDEX_FILE" | xargs echo " $(( $(wc -l < "$INDEX_FILE") - 4 )) 条索引条目"
|
||||
;;
|
||||
|
||||
check)
|
||||
echo "🔍 记忆健康检查 (Hermes 风格)"
|
||||
echo ""
|
||||
|
||||
# 1. 检查索引是否存在
|
||||
if [ -f "$INDEX_FILE" ]; then
|
||||
echo -e " ${GREEN}✅ 索引文件存在${NC}"
|
||||
echo " $(grep -c '^-' "$INDEX_FILE") 条可用索引"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠️ 索引文件缺失,运行 memory-sync.sh index 创建${NC}"
|
||||
fi
|
||||
|
||||
# 2. MEMORY.md 大小检查
|
||||
SIZE=$(wc -c < "$WORKSPACE/MEMORY.md")
|
||||
if [ "$SIZE" -gt 10000 ]; then
|
||||
echo -e " ${YELLOW}⚠️ MEMORY.md 较大: ${SIZE} 字节 (>10K),建议精简${NC}"
|
||||
else
|
||||
echo -e " ${GREEN}✅ MEMORY.md: ${SIZE} 字节${NC}"
|
||||
fi
|
||||
|
||||
# 3. 项目记忆数量
|
||||
PCOUNT=$(find "$PROJECTS_DIR" -name '*.md' 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo -e " ${GREEN}✅ 项目记忆: ${PCOUNT} 个${NC}"
|
||||
|
||||
# 4. 最近的 daily 记录
|
||||
LATEST=$(ls -t "$MEMORY_DIR/daily/" 2>/dev/null | head -1)
|
||||
if [ -n "$LATEST" ]; then
|
||||
echo -e " ${GREEN}✅ 最近 daily: $LATEST${NC}"
|
||||
fi
|
||||
|
||||
# 5. git 状态
|
||||
cd "$WORKSPACE"
|
||||
if [ -n "$(git status --short 2>/dev/null)" ]; then
|
||||
echo -e " ${YELLOW}⚠️ 有未提交的记忆更改${NC}"
|
||||
else
|
||||
echo -e " ${GREEN}✅ 所有记忆更改已提交${NC}"
|
||||
fi
|
||||
;;
|
||||
|
||||
prefetch)
|
||||
KEY="${2:-}"
|
||||
if [ -z "$KEY" ]; then
|
||||
echo "用法: bash scripts/memory-sync.sh prefetch <关键词>"
|
||||
echo "示例: bash scripts/memory-sync.sh prefetch hermes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔎 预取记忆:$KEY"
|
||||
echo ""
|
||||
|
||||
# 1. 查索引
|
||||
if [ -f "$INDEX_FILE" ]; then
|
||||
MATCHES=$(grep -i "$KEY" "$INDEX_FILE" | head -10)
|
||||
if [ -n "$MATCHES" ]; then
|
||||
echo "📋 索引匹配:"
|
||||
echo "$MATCHES"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. 查项目记忆
|
||||
for f in "$PROJECTS_DIR"/*.md; do
|
||||
[ -f "$f" ] || continue
|
||||
if grep -qi "$KEY" "$f"; then
|
||||
name=$(basename "$f" .md)
|
||||
echo "📂 项目记忆匹配:$name"
|
||||
grep -i "$KEY" "$f" | head -5
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
|
||||
# 3. 查 MEMORY.md
|
||||
if grep -qi "$KEY" "$WORKSPACE/MEMORY.md"; then
|
||||
echo "📄 MEMORY.md 匹配:"
|
||||
grep -i "$KEY" "$WORKSPACE/MEMORY.md" | head -5
|
||||
fi
|
||||
;;
|
||||
|
||||
backup)
|
||||
BACKUP_DIR="$WORKSPACE/.memory-backup-$(date '+%Y%m%d-%H%M%S')"
|
||||
echo "💾 备份记忆文件到 $BACKUP_DIR"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
cp "$WORKSPACE/MEMORY.md" "$BACKUP_DIR/" 2>/dev/null || true
|
||||
cp -r "$MEMORY_DIR" "$BACKUP_DIR/" 2>/dev/null || true
|
||||
echo -e "${GREEN}✅ 备份完成${NC}"
|
||||
;;
|
||||
|
||||
compact)
|
||||
echo "🧹 检查 MEMORY.md 是否需要压缩..."
|
||||
LINES=$(wc -l < "$WORKSPACE/MEMORY.md")
|
||||
CHARS=$(wc -c < "$WORKSPACE/MEMORY.md")
|
||||
echo " 当前: ${LINES} 行 / ${CHARS} 字节"
|
||||
if [ "$LINES" -gt 200 ] || [ "$CHARS" -gt 10000 ]; then
|
||||
echo -e " ${YELLOW}⚠️ 需要压缩(超限)${NC}"
|
||||
echo " 压缩策略:"
|
||||
echo " 1. 低优先级条目(环境事实)→ 移至 daily/ 存档"
|
||||
echo " 2. 同类条目合并为一条"
|
||||
echo " 3. 已固化的技能从 MEMORY.md 移入 tools/"
|
||||
else
|
||||
echo -e " ${GREEN}✅ 大小正常${NC}"
|
||||
fi
|
||||
;;
|
||||
|
||||
stats)
|
||||
echo "📊 记忆仓使用统计"
|
||||
echo ""
|
||||
for f in "$WORKSPACE/MEMORY.md" "$MEMORY_DIR/USER.md" "$MEMORY_DIR/index.md"; do
|
||||
if [ -f "$f" ]; then
|
||||
L=$(wc -l < "$f")
|
||||
C=$(wc -c < "$f")
|
||||
echo " $(basename "$f"): ${L} 行 / ${C} 字节"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo " daily/:$(ls "$MEMORY_DIR/daily/" 2>/dev/null | wc -l) 个文件"
|
||||
echo " projects/:$(ls "$MEMORY_DIR/projects/" 2>/dev/null | wc -l) 个文件"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "用法: bash scripts/memory-sync.sh <子命令>"
|
||||
echo ""
|
||||
echo " index 重建记忆索引"
|
||||
echo " check 检查记忆健康状况"
|
||||
echo " prefetch <k> 根据关键词召回相关记忆"
|
||||
echo " backup 备份所有记忆文件"
|
||||
echo " compact 压缩 MEMORY.md(溢出时合并同类条目)"
|
||||
echo " stats 统计各记忆仓使用情况"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,648 @@
|
||||
/**
|
||||
* Shared Model Contract Layer
|
||||
*
|
||||
* 单一字段定义源(Single Source of Truth)。
|
||||
* 所有 agent 通过 import { createContract } from "./model-contract.mjs" 引用,
|
||||
* 禁止自行定义任何字段名。
|
||||
*
|
||||
* 提供:
|
||||
* - entities[] — 所有实体的字段定义(PascalCase 名 + snake_case 表名)
|
||||
* - auth{} — 认证配置
|
||||
* - permissions[] — RBAC 权限矩阵
|
||||
* - fieldMapping{} — snake_case ↔ camelCase 双向映射
|
||||
* - validation{} — 字段级校验规则
|
||||
*/
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 1. Domain-Specific Entity Templates
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Base entity templates for common domains.
|
||||
* These are the canonical field definitions for every entity.
|
||||
* Key: domain name → Array of entity templates.
|
||||
*/
|
||||
const DOMAIN_ENTITIES = {
|
||||
enterprise: [
|
||||
{
|
||||
name: "User",
|
||||
table: "users",
|
||||
description: "系统用户",
|
||||
fields: [
|
||||
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
|
||||
{ name: "username", type: "VARCHAR(128)", required: true, unique: true, validation: { minLength: 2, maxLength: 50 } },
|
||||
{ name: "password_hash", type: "VARCHAR(256)", required: true, isSecret: true },
|
||||
{ name: "nickname", type: "VARCHAR(128)" },
|
||||
{ name: "role", type: "VARCHAR(32)", defaultValue: "user", enum: ["user", "admin"] },
|
||||
{ name: "phone", type: "VARCHAR(20)" },
|
||||
{ name: "avatar_url", type: "TEXT" },
|
||||
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
|
||||
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
|
||||
],
|
||||
relationships: [
|
||||
{ type: "hasMany", entity: "contracts", via: "user_id" },
|
||||
{ type: "hasMany", entity: "customers", via: "user_id" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Contract",
|
||||
table: "contracts",
|
||||
description: "合同",
|
||||
fields: [
|
||||
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
|
||||
{ name: "user_id", type: "UUID", required: true, fkEntity: "users", fkColumn: "id" },
|
||||
{ name: "title", type: "VARCHAR(256)", required: true },
|
||||
{ name: "party_a", type: "VARCHAR(128)" },
|
||||
{ name: "party_b", type: "VARCHAR(128)" },
|
||||
{ name: "amount", type: "DECIMAL(14,2)" },
|
||||
{ name: "signed_at", type: "DATE" },
|
||||
{ name: "expires_at", type: "DATE" },
|
||||
{ name: "status", type: "VARCHAR(32)", defaultValue: "draft", enum: ["draft", "pending", "active", "expired", "terminated"] },
|
||||
{ name: "file_url", type: "TEXT" },
|
||||
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
|
||||
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
|
||||
],
|
||||
relationships: [
|
||||
{ type: "belongsTo", entity: "users", via: "user_id" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Approval",
|
||||
table: "approvals",
|
||||
description: "审批流程",
|
||||
fields: [
|
||||
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
|
||||
{ name: "user_id", type: "UUID", required: true, fkEntity: "users", fkColumn: "id" },
|
||||
{ name: "entity_type", type: "VARCHAR(32)", required: true },
|
||||
{ name: "entity_id", type: "UUID" },
|
||||
{ name: "applicant_id", type: "UUID", required: true, fkEntity: "users", fkColumn: "id" },
|
||||
{ name: "status", type: "VARCHAR(32)", defaultValue: "pending", enum: ["pending", "approved", "rejected"] },
|
||||
{ name: "form_data", type: "JSONB" },
|
||||
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
|
||||
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
|
||||
],
|
||||
relationships: [
|
||||
{ type: "belongsTo", entity: "users", via: "user_id" },
|
||||
{ type: "belongsTo", entity: "users", via: "applicant_id", as: "applicant" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Customer",
|
||||
table: "customers",
|
||||
description: "客户",
|
||||
fields: [
|
||||
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
|
||||
{ name: "user_id", type: "UUID", required: true, fkEntity: "users", fkColumn: "id" },
|
||||
{ name: "name", type: "VARCHAR(128)", required: true },
|
||||
{ name: "email", type: "VARCHAR(256)" },
|
||||
{ name: "phone", type: "VARCHAR(20)" },
|
||||
{ name: "company", type: "VARCHAR(128)" },
|
||||
{ name: "source", type: "VARCHAR(64)" },
|
||||
{ name: "tags", type: "JSONB", defaultValue: "[]" },
|
||||
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
|
||||
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
|
||||
],
|
||||
relationships: [
|
||||
{ type: "belongsTo", entity: "users", via: "user_id" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Reminder",
|
||||
table: "reminders",
|
||||
description: "到期提醒",
|
||||
fields: [
|
||||
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
|
||||
{ name: "user_id", type: "UUID", required: true, fkEntity: "users", fkColumn: "id" },
|
||||
{ name: "entity_type", type: "VARCHAR(32)", required: true },
|
||||
{ name: "entity_id", type: "UUID" },
|
||||
{ name: "remind_at", type: "TIMESTAMP", required: true },
|
||||
{ name: "message", type: "TEXT" },
|
||||
{ name: "sent", type: "BOOLEAN", defaultValue: "false" },
|
||||
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
|
||||
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
|
||||
],
|
||||
relationships: [
|
||||
{ type: "belongsTo", entity: "users", via: "user_id" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
pet: [
|
||||
{
|
||||
name: "User",
|
||||
table: "users",
|
||||
description: "系统用户",
|
||||
fields: [
|
||||
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
|
||||
{ name: "username", type: "VARCHAR(128)", required: true, unique: true, validation: { minLength: 2, maxLength: 50 } },
|
||||
{ name: "password_hash", type: "VARCHAR(256)", required: true, isSecret: true },
|
||||
{ name: "nickname", type: "VARCHAR(128)" },
|
||||
{ name: "role", type: "VARCHAR(32)", defaultValue: "user", enum: ["user", "admin"] },
|
||||
{ name: "phone", type: "VARCHAR(20)" },
|
||||
{ name: "avatar_url", type: "TEXT" },
|
||||
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
|
||||
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
|
||||
],
|
||||
relationships: [],
|
||||
},
|
||||
{
|
||||
name: "Pet",
|
||||
table: "pets",
|
||||
description: "宠物",
|
||||
fields: [
|
||||
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
|
||||
{ name: "user_id", type: "UUID", required: true, fkEntity: "users", fkColumn: "id" },
|
||||
{ name: "name", type: "VARCHAR(64)", required: true },
|
||||
{ name: "species", type: "VARCHAR(32)" },
|
||||
{ name: "breed", type: "VARCHAR(64)" },
|
||||
{ name: "birth_date", type: "DATE" },
|
||||
{ name: "weight_kg", type: "DECIMAL(5,2)" },
|
||||
{ name: "avatar_url", type: "TEXT" },
|
||||
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
|
||||
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
|
||||
],
|
||||
relationships: [
|
||||
{ type: "belongsTo", entity: "users", via: "user_id" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
ecommerce: [
|
||||
{
|
||||
name: "User",
|
||||
table: "users",
|
||||
description: "系统用户",
|
||||
fields: [
|
||||
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
|
||||
{ name: "username", type: "VARCHAR(128)", required: true, unique: true, validation: { minLength: 2, maxLength: 50 } },
|
||||
{ name: "password_hash", type: "VARCHAR(256)", required: true, isSecret: true },
|
||||
{ name: "nickname", type: "VARCHAR(128)" },
|
||||
{ name: "role", type: "VARCHAR(32)", defaultValue: "user", enum: ["user", "admin"] },
|
||||
{ name: "phone", type: "VARCHAR(20)" },
|
||||
{ name: "avatar_url", type: "TEXT" },
|
||||
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
|
||||
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
|
||||
],
|
||||
relationships: [],
|
||||
},
|
||||
{
|
||||
name: "Product",
|
||||
table: "products",
|
||||
description: "商品",
|
||||
fields: [
|
||||
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
|
||||
{ name: "user_id", type: "UUID", required: true, fkEntity: "users", fkColumn: "id" },
|
||||
{ name: "title", type: "VARCHAR(256)", required: true },
|
||||
{ name: "description", type: "TEXT" },
|
||||
{ name: "price", type: "DECIMAL(10,2)" },
|
||||
{ name: "stock", type: "INTEGER", defaultValue: "0" },
|
||||
{ name: "images", type: "JSONB", defaultValue: "[]" },
|
||||
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
|
||||
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
|
||||
],
|
||||
relationships: [
|
||||
{ type: "belongsTo", entity: "users", via: "user_id" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Order",
|
||||
table: "orders",
|
||||
description: "订单",
|
||||
fields: [
|
||||
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
|
||||
{ name: "user_id", type: "UUID", required: true, fkEntity: "users", fkColumn: "id" },
|
||||
{ name: "status", type: "VARCHAR(32)", defaultValue: "pending", enum: ["pending", "paid", "shipped", "delivered", "cancelled"] },
|
||||
{ name: "total_amount", type: "DECIMAL(12,2)" },
|
||||
{ name: "address_id", type: "UUID", fkEntity: "addresses", fkColumn: "id" },
|
||||
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
|
||||
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
|
||||
],
|
||||
relationships: [
|
||||
{ type: "belongsTo", entity: "users", via: "user_id" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
education: [
|
||||
{
|
||||
name: "User",
|
||||
table: "users",
|
||||
description: "系统用户",
|
||||
fields: [
|
||||
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
|
||||
{ name: "username", type: "VARCHAR(128)", required: true, unique: true, validation: { minLength: 2, maxLength: 50 } },
|
||||
{ name: "password_hash", type: "VARCHAR(256)", required: true, isSecret: true },
|
||||
{ name: "nickname", type: "VARCHAR(128)" },
|
||||
{ name: "role", type: "VARCHAR(32)", defaultValue: "user", enum: ["user", "admin", "instructor"] },
|
||||
{ name: "phone", type: "VARCHAR(20)" },
|
||||
{ name: "avatar_url", type: "TEXT" },
|
||||
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
|
||||
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
|
||||
],
|
||||
relationships: [],
|
||||
},
|
||||
],
|
||||
|
||||
note: [
|
||||
{
|
||||
name: "User",
|
||||
table: "users",
|
||||
description: "系统用户",
|
||||
fields: [
|
||||
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
|
||||
{ name: "username", type: "VARCHAR(128)", required: true, unique: true, validation: { minLength: 2, maxLength: 50 } },
|
||||
{ name: "password_hash", type: "VARCHAR(256)", required: true, isSecret: true },
|
||||
{ name: "nickname", type: "VARCHAR(128)" },
|
||||
{ name: "role", type: "VARCHAR(32)", defaultValue: "user", enum: ["user", "admin"] },
|
||||
{ name: "phone", type: "VARCHAR(20)" },
|
||||
{ name: "avatar_url", type: "TEXT" },
|
||||
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
|
||||
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
|
||||
],
|
||||
relationships: [],
|
||||
},
|
||||
],
|
||||
|
||||
fitness: [
|
||||
{
|
||||
name: "User",
|
||||
table: "users",
|
||||
description: "系统用户",
|
||||
fields: [
|
||||
{ name: "id", type: "UUID", isPrimary: true, isAuto: true },
|
||||
{ name: "username", type: "VARCHAR(128)", required: true, unique: true, validation: { minLength: 2, maxLength: 50 } },
|
||||
{ name: "password_hash", type: "VARCHAR(256)", required: true, isSecret: true },
|
||||
{ name: "nickname", type: "VARCHAR(128)" },
|
||||
{ name: "role", type: "VARCHAR(32)", defaultValue: "user", enum: ["user", "admin"] },
|
||||
{ name: "phone", type: "VARCHAR(20)" },
|
||||
{ name: "avatar_url", type: "TEXT" },
|
||||
{ name: "created_at", type: "TIMESTAMP", isAuto: true },
|
||||
{ name: "updated_at", type: "TIMESTAMP", isAuto: true },
|
||||
],
|
||||
relationships: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 2. Field Mapping Engine
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Build bidirectional snake_case ↔ camelCase mappings from all contract entities.
|
||||
* Scanned once; cached in the returned contract.
|
||||
*/
|
||||
function buildFieldMappings(entities) {
|
||||
const snakeToCamel = {};
|
||||
const camelToSnake = {};
|
||||
|
||||
// Base conversions from common SQL columns
|
||||
const baseMappings = {
|
||||
snakeToCamel: {
|
||||
"created_at": "createdAt",
|
||||
"updated_at": "updatedAt",
|
||||
"user_id": "userId",
|
||||
},
|
||||
camelToSnake: {
|
||||
"createdAt": "created_at",
|
||||
"updatedAt": "updated_at",
|
||||
"userId": "user_id",
|
||||
},
|
||||
};
|
||||
|
||||
Object.assign(snakeToCamel, baseMappings.snakeToCamel);
|
||||
Object.assign(camelToSnake, baseMappings.camelToSnake);
|
||||
|
||||
for (const entity of entities) {
|
||||
for (const field of entity.fields) {
|
||||
const snake = field.name; // fields are stored in snake_case
|
||||
// Convert to camelCase
|
||||
const camel = snake.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
||||
|
||||
if (snake !== camel) {
|
||||
snakeToCamel[snake] = camel;
|
||||
camelToSnake[camel] = snake;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add common patterns
|
||||
const known = [
|
||||
["id", "id"],
|
||||
["username", "username"],
|
||||
["password_hash", "passwordHash"],
|
||||
["nickname", "nickname"],
|
||||
["role", "role"],
|
||||
["phone", "phone"],
|
||||
["avatar_url", "avatarUrl"],
|
||||
["title", "title"],
|
||||
["description", "description"],
|
||||
["status", "status"],
|
||||
["data", "data"],
|
||||
["amount", "amount"],
|
||||
["party_a", "partyA"],
|
||||
["party_b", "partyB"],
|
||||
["signed_at", "signedAt"],
|
||||
["expires_at", "expiresAt"],
|
||||
["file_url", "fileUrl"],
|
||||
["entity_type", "entityType"],
|
||||
["entity_id", "entityId"],
|
||||
["applicant_id", "applicantId"],
|
||||
["form_data", "formData"],
|
||||
["name", "name"],
|
||||
["email", "email"],
|
||||
["company", "company"],
|
||||
["source", "source"],
|
||||
["tags", "tags"],
|
||||
["breed", "breed"],
|
||||
["species", "species"],
|
||||
["birth_date", "birthDate"],
|
||||
["weight_kg", "weightKg"],
|
||||
["price", "price"],
|
||||
["stock", "stock"],
|
||||
["images", "images"],
|
||||
["total_amount", "totalAmount"],
|
||||
["address_id", "addressId"],
|
||||
["remind_at", "remindAt"],
|
||||
["message", "message"],
|
||||
["sent", "sent"],
|
||||
];
|
||||
|
||||
for (const [snake, camel] of known) {
|
||||
if (!snakeToCamel[snake]) snakeToCamel[snake] = camel;
|
||||
if (!camelToSnake[camel]) camelToSnake[camel] = snake;
|
||||
}
|
||||
|
||||
return { snakeToCamel, camelToSnake };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 3. Auth Configuration
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function buildAuthConfig(domain) {
|
||||
// Enterprise domain has stricter auth
|
||||
const isEnterprise = domain === "enterprise";
|
||||
|
||||
return {
|
||||
registrationFields: ["username", "password", "nickname"],
|
||||
loginFields: ["username", "password"],
|
||||
jwtPayload: ["userId", "username", "role"],
|
||||
passwordPolicy: {
|
||||
minLength: isEnterprise ? 8 : 6,
|
||||
requireSpecial: isEnterprise,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 4. Permissions Matrix
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function buildPermissions(domain) {
|
||||
return [
|
||||
{ role: "admin", allow: ["*"] },
|
||||
{ role: "user", allow: ["read:own", "create:*", "update:own", "delete:own"] },
|
||||
];
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 5. Validation Rules
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function buildValidation(entities) {
|
||||
const rules = {};
|
||||
|
||||
for (const entity of entities) {
|
||||
const fieldRules = {};
|
||||
for (const field of entity.fields) {
|
||||
if (field.validation) {
|
||||
fieldRules[field.name] = { ...field.validation };
|
||||
}
|
||||
// Auto-generate from field properties
|
||||
if (field.required || field.unique || field.enum || field.defaultValue !== undefined) {
|
||||
if (!fieldRules[field.name]) fieldRules[field.name] = {};
|
||||
}
|
||||
if (field.required) {
|
||||
fieldRules[field.name].required = true;
|
||||
}
|
||||
if (field.unique) {
|
||||
fieldRules[field.name].unique = true;
|
||||
}
|
||||
if (field.enum) {
|
||||
fieldRules[field.name].enum = field.enum;
|
||||
}
|
||||
if (field.defaultValue !== undefined) {
|
||||
fieldRules[field.name].defaultValue = field.defaultValue;
|
||||
}
|
||||
}
|
||||
if (Object.keys(fieldRules).length > 0) {
|
||||
rules[entity.name] = fieldRules;
|
||||
}
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 6. Main Contract Builder
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Create a Model Contract from PRD and Architecture.
|
||||
*
|
||||
* The contract is the SINGLE SOURCE OF TRUTH for all entity field definitions.
|
||||
* All generators MUST read from the contract; they MUST NOT invent field names.
|
||||
*
|
||||
* @param {object} prd — PRD from SF-01 (project-intake-agent)
|
||||
* @param {object} arch — Architecture from SF-02 (architecture-agent), optional
|
||||
* @returns {object} Contract object
|
||||
*/
|
||||
export function createContract(prd, arch) {
|
||||
const domain = (prd && prd.domain) || (arch && arch.domain) || "generic";
|
||||
const projectName = (prd && prd.projectName) || (arch && arch.projectName) || "App";
|
||||
|
||||
// ── Get domain-specific entities ──
|
||||
let entities = DOMAIN_ENTITIES[domain] || DOMAIN_ENTITIES.enterprise;
|
||||
|
||||
// Deep clone to avoid mutation
|
||||
entities = JSON.parse(JSON.stringify(entities));
|
||||
|
||||
// ── Augment with domain-specific entities from features/APIs ──
|
||||
// If architecture has derived tables, merge them into the contract
|
||||
if (arch && arch.databaseSchema) {
|
||||
const existingTables = new Set(entities.map(e => e.table));
|
||||
const existingNames = new Set(entities.map(e => e.name));
|
||||
|
||||
for (const table of arch.databaseSchema) {
|
||||
if (!existingTables.has(table.table) && table.table !== "users") {
|
||||
// Auto-generate PascalCase entity name from snake_case table
|
||||
const pascalName = table.table
|
||||
.split("_")
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join("");
|
||||
|
||||
if (!existingNames.has(pascalName)) {
|
||||
const entity = {
|
||||
name: pascalName,
|
||||
table: table.table,
|
||||
description: table.description || `${pascalName}表`,
|
||||
fields: (table.fields || []).map(f => ({
|
||||
name: f.name,
|
||||
type: f.type || "TEXT",
|
||||
required: (f.constraints || "").includes("NOT NULL") || (f.constraints || "").includes("PK"),
|
||||
isPrimary: (f.constraints || "").includes("PK"),
|
||||
isAuto: f.name === "created_at" || f.name === "updated_at" || (f.constraints || "").includes("DEFAULT"),
|
||||
...((f.constraints || "").includes("UNIQUE") ? { unique: true } : {}),
|
||||
...((f.constraints || "").includes("FK") ? {
|
||||
fkEntity: (f.name.endsWith("_id") ? f.name.replace(/_id$/, "s") : "items"),
|
||||
fkColumn: "id",
|
||||
} : {}),
|
||||
})),
|
||||
relationships: [],
|
||||
};
|
||||
|
||||
// Run FK detection on fields
|
||||
for (const field of entity.fields) {
|
||||
if (field.fkEntity) {
|
||||
entity.relationships.push({
|
||||
type: "belongsTo",
|
||||
entity: field.fkEntity,
|
||||
via: field.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entities.push(entity);
|
||||
existingTables.add(table.table);
|
||||
existingNames.add(pascalName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build mappings ──
|
||||
const fieldMapping = buildFieldMappings(entities);
|
||||
const auth = buildAuthConfig(domain);
|
||||
const permissions = buildPermissions(domain);
|
||||
const validation = buildValidation(entities);
|
||||
|
||||
return {
|
||||
projectName,
|
||||
domain,
|
||||
entities,
|
||||
auth,
|
||||
permissions,
|
||||
fieldMapping,
|
||||
validation,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 7. Contract Utility Functions
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Look up a field in the contract by entity.table and field name (snake_case).
|
||||
*
|
||||
* @param {object} contract
|
||||
* @param {string} tableName — snake_case table name
|
||||
* @param {string} fieldName — snake_case field name
|
||||
* @returns {object|null} Field definition
|
||||
*/
|
||||
export function lookupField(contract, tableName, fieldName) {
|
||||
for (const entity of contract.entities) {
|
||||
if (entity.table === tableName) {
|
||||
return entity.fields.find(f => f.name === fieldName) || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the camelCase name for a snake_case column from the contract's fieldMapping.
|
||||
*
|
||||
* @param {object} contract
|
||||
* @param {string} snakeName
|
||||
* @returns {string}
|
||||
*/
|
||||
export function toCamel(contract, snakeName) {
|
||||
return contract.fieldMapping.snakeToCamel[snakeName] || snakeName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the snake_case name for a camelCase property from the contract's fieldMapping.
|
||||
*
|
||||
* @param {object} contract
|
||||
* @param {string} camelName
|
||||
* @returns {string}
|
||||
*/
|
||||
export function toSnake(contract, camelName) {
|
||||
return contract.fieldMapping.camelToSnake[camelName] || camelName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the TypeScript type for a contract field.
|
||||
*
|
||||
* @param {object} field — Contract field definition
|
||||
* @returns {string} TypeScript type string
|
||||
*/
|
||||
export function tsType(field) {
|
||||
const t = (field.type || "").toUpperCase();
|
||||
if (t.includes("INT") || t.includes("SERIAL") || t.includes("BIGINT") || t.includes("DECIMAL") || t.includes("NUMERIC") || t.includes("FLOAT") || t.includes("DOUBLE") || t.includes("REAL")) return "number";
|
||||
if (t.includes("BOOL")) return "boolean";
|
||||
if (t.includes("JSONB") || t.includes("JSON")) return "Record<string, unknown>";
|
||||
return "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SQLite column type for a contract field.
|
||||
*
|
||||
* @param {object} field — Contract field definition
|
||||
* @returns {string} SQLite type
|
||||
*/
|
||||
export function sqliteType(field) {
|
||||
const t = (field.type || "").toUpperCase();
|
||||
if (t.includes("UUID") || t.includes("TEXT") || t.includes("VARCHAR") || t.includes("CHAR")) return "TEXT";
|
||||
if (t.includes("INT") || t.includes("SERIAL") || t.includes("BIGINT")) return "INTEGER";
|
||||
if (t.includes("DECIMAL") || t.includes("NUMERIC") || t.includes("FLOAT") || t.includes("DOUBLE") || t.includes("REAL")) return "REAL";
|
||||
if (t.includes("BOOL")) return "INTEGER";
|
||||
if (t.includes("DATE") || t.includes("TIME") || t.includes("TIMESTAMP")) return "TEXT";
|
||||
if (t.includes("JSONB") || t.includes("JSON")) return "TEXT";
|
||||
return "TEXT";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the non-auto fields for CreateInput (exclude id, created_at, updated_at).
|
||||
*
|
||||
* @param {object} entity — Contract entity
|
||||
* @returns {object[]} Fields for CreateInput
|
||||
*/
|
||||
export function createInputFields(entity) {
|
||||
return entity.fields.filter(f =>
|
||||
!f.isPrimary && !f.isAuto && f.name !== "created_at" && f.name !== "updated_at"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the non-identity fields for UpdateInput (exclude id, created_at, updated_at).
|
||||
*
|
||||
* @param {object} entity — Contract entity
|
||||
* @returns {object[]} Fields for UpdateInput
|
||||
*/
|
||||
export function updateInputFields(entity) {
|
||||
return createInputFields(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an entity has the given field (by snake_case name).
|
||||
*/
|
||||
export function hasField(entity, snakeName) {
|
||||
return entity.fields.some(f => f.name === snakeName);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 8. Export DOMAIN_ENTITIES for introspection
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
export { DOMAIN_ENTITIES };
|
||||
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Orchestrator — V2 Protocol Stack → Factory Pipeline Bridge
|
||||
*
|
||||
* Minimal orchestrator that:
|
||||
* 1. Accepts user input (natural language)
|
||||
* 2. Generates a Task Tree + Plan record (AGENTS.md §1–2)
|
||||
* 3. Routes to the correct factory chain via Factory Router
|
||||
* 4. Captures Governance-compliant heartbeat into progress.log
|
||||
* 5. Returns structured result summary
|
||||
*
|
||||
* Does NOT:
|
||||
* - Re-implement AGENTS.md / GOVERNANCE.md rules
|
||||
* - Run state machines
|
||||
* - Replace existing CLI entry points
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/orchestrator.mjs --input "做一个宠物管理 App" [--output <dir>]
|
||||
* node scripts/orchestrator.mjs --input "做一个在线教育平台" --domain education
|
||||
*
|
||||
* @module orchestrator
|
||||
*/
|
||||
|
||||
import { writeFileSync, appendFileSync, existsSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, "..");
|
||||
|
||||
// ── Constants ──────────────────────────────────────
|
||||
|
||||
const DEFAULT_OUTPUT = resolve(ROOT, "fullstack");
|
||||
const PROGRESS_LOG_PATH = resolve(ROOT, "progress.log");
|
||||
|
||||
// ── CLI ────────────────────────────────────────────
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const opts = { input: null, output: null, domain: null, verbose: false, help: false };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--input" && args[i + 1]) opts.input = args[++i];
|
||||
else if (args[i] === "--output" && args[i + 1]) opts.output = args[++i];
|
||||
else if (args[i] === "--domain" && args[i + 1]) opts.domain = args[++i];
|
||||
else if (args[i] === "--verbose") opts.verbose = true;
|
||||
else if (args[i] === "--help" || args[i] === "-h") opts.help = true;
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs();
|
||||
|
||||
if (opts.help) {
|
||||
console.log(`
|
||||
Orchestrator — V2 Protocol Stack ↔ Factory Pipeline
|
||||
|
||||
Usage:
|
||||
node scripts/orchestrator.mjs --input <text> [options]
|
||||
|
||||
Options:
|
||||
--input <text> 用户需求描述(必填)
|
||||
--output <dir> 输出目录(default: fullstack/)
|
||||
--domain <name> 强制领域(override auto-detect)
|
||||
--verbose 详细输出
|
||||
--help 显示帮助
|
||||
|
||||
Examples:
|
||||
node scripts/orchestrator.mjs --input "做一个宠物管理 App"
|
||||
node scripts/orchestrator.mjs --input "做一个在线教育平台" --output ./my-project
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opts.input) {
|
||||
console.error("Error: --input is required. Use --help for usage.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await run(opts.input, opts);
|
||||
console.log(JSON.stringify(result.summary, null, 2));
|
||||
} catch (e) {
|
||||
console.error(`Orchestrator failed: ${e.message}`);
|
||||
// Write failure heartbeat
|
||||
writeHeartbeat({ status: "FAILED", remaining: [], risk: e.message, next_action: "investigate failure" });
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main Pipeline ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Orchestrator run — the unified entry point.
|
||||
*
|
||||
* @param {string} input — User's requirement description
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.output] — Output directory
|
||||
* @param {string} [opts.domain] — Force domain override
|
||||
* @returns {Promise<{summary: object, plan: object, taskTree: object, heartbeat: object}>}
|
||||
*/
|
||||
export async function run(input, opts = {}) {
|
||||
const startTs = new Date().toISOString();
|
||||
const plan = buildPlan(input);
|
||||
|
||||
// ── 1. Context Init & Heartbeat ──
|
||||
writeHeartbeat(plan.heartbeat);
|
||||
|
||||
// ── 2. V2 Task Tree ──
|
||||
const taskTree = buildTaskTree(input, opts.domain);
|
||||
|
||||
// ── 3. SF-01: Project Intake (PRD generation) ──
|
||||
const sf01 = await import("./project-intake-agent.mjs");
|
||||
writeHeartbeat({ ...plan.heartbeat, status: "IN_PROGRESS", completed: ["T1"], remaining: plan.heartbeat.remaining.slice(1), next_action: "SF-02: generate architecture" });
|
||||
|
||||
const prd = sf01.generatePRD(input);
|
||||
if (prd.error === "EMPTY_INPUT") {
|
||||
throw new Error("PRD generation failed: empty input");
|
||||
}
|
||||
if (prd.error === "EXTRACTION_FAILED") {
|
||||
throw new Error(prd.message);
|
||||
}
|
||||
|
||||
// ── 4. SF-02: Architecture generation ──
|
||||
const sf02 = await import("./architecture-agent.mjs");
|
||||
writeHeartbeat({ ...plan.heartbeat, status: "IN_PROGRESS", completed: ["T1", "T2"], remaining: plan.heartbeat.remaining.slice(2), next_action: "SF-03~06: fullstack compose" });
|
||||
|
||||
const arch = sf02.generateArchitecture(prd);
|
||||
if (arch.error) {
|
||||
throw new Error(`Architecture generation failed: ${arch.message}`);
|
||||
}
|
||||
|
||||
// ── 5. Factory Router: route to fullstack composer ──
|
||||
const router = await import("./factory-router.mjs");
|
||||
const routeResult = router.route(prd, { arch });
|
||||
const factoryMod = await router.loadFactory(routeResult.factoryFn);
|
||||
|
||||
writeHeartbeat({ ...plan.heartbeat, status: "IN_PROGRESS", completed: ["T1", "T2", "T3"], remaining: plan.heartbeat.remaining.slice(3), next_action: "writing output" });
|
||||
|
||||
const result = await factoryMod.composeFullstack(prd, arch);
|
||||
|
||||
// ── 6. Write output ──
|
||||
const outputDir = opts.output ? resolve(opts.output) : DEFAULT_OUTPUT;
|
||||
factoryMod.writeFullstack(result, outputDir);
|
||||
|
||||
// ── 7. Governance: final heartbeat ──
|
||||
writeHeartbeat({ ...plan.heartbeat, status: "ARCHIVED", completed: ["T1", "T2", "T3", "T4"], remaining: [], next_action: "none" });
|
||||
|
||||
const endTs = new Date().toISOString();
|
||||
const durationMs = Date.now() - new Date(startTs).getTime();
|
||||
|
||||
// ── 8. Summary ──
|
||||
const summary = {
|
||||
projectName: prd.projectName,
|
||||
chineseName: prd.chineseName || prd.projectName,
|
||||
domain: prd.domain,
|
||||
factory: routeResult.factory,
|
||||
outputDir,
|
||||
stats: result.stats,
|
||||
startTs,
|
||||
endTs,
|
||||
durationMs,
|
||||
};
|
||||
|
||||
return { summary, plan, taskTree, heartbeat: plan.heartbeat };
|
||||
}
|
||||
|
||||
// ── V2 Protocol Artifacts ──────────────────────────
|
||||
|
||||
/**
|
||||
* Build Task Tree per AGENTS.md §1.
|
||||
*
|
||||
* @param {string} input
|
||||
* @param {string} [domainOverride]
|
||||
* @returns {object} Task Tree
|
||||
*/
|
||||
function buildTaskTree(input, domainOverride) {
|
||||
return {
|
||||
root: {
|
||||
id: "T0",
|
||||
name: "Full-stack Product Generation",
|
||||
depends_on: null,
|
||||
description: `Generate full-stack product from: "${input.slice(0, 80)}"`,
|
||||
},
|
||||
tasks: [
|
||||
{ id: "T1", name: "SF-01 Product Strategy", depends_on: ["T0"], description: "Parse requirements → PRD", parallelizable: false },
|
||||
{ id: "T2", name: "SF-02 Architecture Design", depends_on: ["T1"], description: "PRD → Architecture JSON", parallelizable: false },
|
||||
{ id: "T3", name: "SF-03~06 Full-stack Generation", depends_on: ["T2"], description: "Arch → NestJS + React code", parallelizable: false },
|
||||
{ id: "T4", name: "Governance Verification", depends_on: ["T3"], description: "Verify output, write audit", parallelizable: false },
|
||||
],
|
||||
parallelGroups: [],
|
||||
injectedRisks: [
|
||||
"Domain match may fallback to generic → verify PRD.domain",
|
||||
"EXTRACTION_FAILED if input too vague → need detailed input",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Plan per AGENTS.md §2.
|
||||
*
|
||||
* @param {string} input
|
||||
* @returns {object} Plan + initial heartbeat
|
||||
*/
|
||||
function buildPlan(input) {
|
||||
const subtasks = [
|
||||
"T1: SF-01 Product Strategy — parse input, match domain, generate PRD",
|
||||
"T2: SF-02 Architecture — generate arch.json from PRD",
|
||||
"T3: SF-03~06 Full-stack — compose NestJS + React project",
|
||||
"T4: Write output files and finalize",
|
||||
];
|
||||
|
||||
const heartbeat = {
|
||||
ts: new Date().toISOString(),
|
||||
status: "IN_PROGRESS",
|
||||
completed: [],
|
||||
remaining: ["T1", "T2", "T3", "T4"],
|
||||
risk: "",
|
||||
next_action: "SF-01: generate PRD",
|
||||
};
|
||||
|
||||
return {
|
||||
goal: `Generate a full-stack web application from: "${input.slice(0, 100)}"`,
|
||||
subtasks,
|
||||
risks: [
|
||||
"Input may be too vague → EXTRACTION_FAILED",
|
||||
"Domain template may not fully match → generic fallback",
|
||||
],
|
||||
verificationStrategy: {
|
||||
build: true,
|
||||
tests: true,
|
||||
lint: false,
|
||||
},
|
||||
heartbeat,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Governance Logging ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Append a heartbeat entry to progress.log.
|
||||
*
|
||||
* Format: JSON Lines (GOVERNANCE.md §2.4)
|
||||
*
|
||||
* @param {object} entry
|
||||
*/
|
||||
function writeHeartbeat(entry) {
|
||||
try {
|
||||
const d = dirname(PROGRESS_LOG_PATH);
|
||||
mkdirSync(d, { recursive: true });
|
||||
appendFileSync(PROGRESS_LOG_PATH, JSON.stringify(entry) + "\n");
|
||||
} catch (e) {
|
||||
console.error(`[orchestrator] heartbeat write failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── CLI Bootstrap ─────────────────────────────────
|
||||
|
||||
if (process.argv[1] && (import.meta.url === `file://${process.argv[1]}` || import.meta.url.endsWith(process.argv[1]))) {
|
||||
main();
|
||||
}
|
||||
Executable
+372
@@ -0,0 +1,372 @@
|
||||
#!/bin/bash
|
||||
# ====================================================
|
||||
# PR-D Production Check — OpenClaw Agent OS v2
|
||||
# ====================================================
|
||||
# One-shot safety gate: env → baseline → guard →
|
||||
# deprecation → smoke → report.
|
||||
# Every architecture PR must pass this gate first.
|
||||
# ====================================================
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
# ─── Result state ───────────────────────────────────
|
||||
ENV_PASS=false
|
||||
BASELINE_PASS=false
|
||||
GUARD_PASS=false
|
||||
DEPRECATION_STATUS="PASS"
|
||||
SMOKE_PASS=false
|
||||
OVERALL_PASS=true
|
||||
|
||||
FAILED_STEP=""
|
||||
FAIL_REASON=""
|
||||
FAIL_FIX=""
|
||||
|
||||
BASELINE_COUNT=0
|
||||
BASELINE_DURATION=0
|
||||
BASELINE_LOG=""
|
||||
DEPRECATION_COUNT=0
|
||||
|
||||
# ─── Helpers ────────────────────────────────────────
|
||||
|
||||
fail_step() {
|
||||
local step="$1" reason="$2" fix="$3"
|
||||
FAILED_STEP="$step"
|
||||
FAIL_REASON="$reason"
|
||||
FAIL_FIX="$fix"
|
||||
OVERALL_PASS=false
|
||||
}
|
||||
|
||||
section() {
|
||||
echo ""
|
||||
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
|
||||
echo -e "${CYAN} $1${NC}"
|
||||
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────
|
||||
# Step 1: Environment Check
|
||||
# ─────────────────────────────────────────────────────
|
||||
section "Step 1: Environment Check"
|
||||
|
||||
check_ok() { echo -e " ${GREEN}✓${NC} $1"; }
|
||||
check_fail() { echo -e " ${RED}✗${NC} $1"; fail_step "Environment" "$1" "$2"; }
|
||||
|
||||
# Node.js version
|
||||
NODE_VERSION=$(node --version 2>/dev/null || echo "")
|
||||
if [ -z "$NODE_VERSION" ]; then
|
||||
check_fail "Node.js not found" "Install Node.js >= 18"
|
||||
else
|
||||
NODE_MAJOR=$(echo "$NODE_VERSION" | sed 's/v//' | cut -d. -f1)
|
||||
if [ "$NODE_MAJOR" -lt 18 ]; then
|
||||
check_fail "Node.js $NODE_VERSION too old (need >=18)" "Upgrade Node.js: nvm install 18"
|
||||
else
|
||||
check_ok "Node.js $NODE_VERSION"
|
||||
fi
|
||||
fi
|
||||
|
||||
# npm
|
||||
if command -v npm &>/dev/null; then
|
||||
NPM_VERSION=$(npm --version 2>/dev/null)
|
||||
check_ok "npm $NPM_VERSION"
|
||||
else
|
||||
check_fail "npm not found" "Install npm (bundled with Node.js)"
|
||||
fi
|
||||
|
||||
# openclaw CLI
|
||||
if command -v openclaw &>/dev/null; then
|
||||
OC_VERSION=$(openclaw --version 2>/dev/null || echo "unknown")
|
||||
check_ok "openclaw CLI ($OC_VERSION)"
|
||||
else
|
||||
check_fail "openclaw CLI not found" "Install openclaw: npm i -g openclaw"
|
||||
fi
|
||||
|
||||
# Working directory
|
||||
if [ -f "package.json" ]; then
|
||||
check_ok "Working directory: $ROOT_DIR"
|
||||
else
|
||||
check_fail "Not in workspace root (no package.json)" "cd to workspace root"
|
||||
fi
|
||||
|
||||
# Required directories
|
||||
for dir in "test/baseline" "test/architecture" "scripts"; do
|
||||
if [ -d "$dir" ]; then
|
||||
check_ok "Directory exists: $dir"
|
||||
else
|
||||
check_fail "Directory missing: $dir" "Create $dir before running"
|
||||
fi
|
||||
done
|
||||
|
||||
if $OVERALL_PASS; then
|
||||
ENV_PASS=true
|
||||
echo ""
|
||||
echo -e " ${GREEN}Environment: PASS${NC}"
|
||||
else
|
||||
ENV_PASS=false
|
||||
echo ""
|
||||
echo -e " ${RED}Environment: FAIL${NC}"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────
|
||||
# Step 2: Baseline Tests
|
||||
# ─────────────────────────────────────────────────────
|
||||
section "Step 2: Baseline Tests"
|
||||
|
||||
BASELINE_START=$(date +%s)
|
||||
|
||||
# Run baseline tests directly (not via buggy test-baseline.sh which has wrong TEST_DIR)
|
||||
BASELINE_LOG=$(mktemp)
|
||||
BASELINE_FILES=$(ls test/baseline/test-*.test.mjs 2>/dev/null || echo "")
|
||||
BASELINE_TOTAL=$(echo "$BASELINE_FILES" | wc -w | tr -d ' ')
|
||||
|
||||
if [ -z "$BASELINE_FILES" ] || [ "$BASELINE_TOTAL" -eq 0 ]; then
|
||||
echo -e " ${RED}✗${NC} No baseline test files found"
|
||||
fail_step "Baseline Tests" "No test files in test/baseline/" "Ensure PR-A baseline tests exist"
|
||||
else
|
||||
echo " Running $BASELINE_TOTAL baseline test files..."
|
||||
echo ""
|
||||
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
|
||||
for f in $BASELINE_FILES; do
|
||||
name=$(basename "$f" .test.mjs)
|
||||
echo -ne " [RUN ] $name ... "
|
||||
|
||||
if node --test "$f" >> "$BASELINE_LOG" 2>&1; then
|
||||
echo -e "${GREEN}PASS${NC}"
|
||||
PASSED=$((PASSED + 1))
|
||||
else
|
||||
echo -e "${RED}FAIL${NC}"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo " ──────────────────────────────────────────"
|
||||
echo " Test files: $BASELINE_TOTAL"
|
||||
echo " Passed: $PASSED"
|
||||
echo " Failed: $FAILED"
|
||||
echo " Log: $BASELINE_LOG"
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
fail_step "Baseline Tests" "$FAILED/$BASELINE_TOTAL tests failed" "Check $BASELINE_LOG for details"
|
||||
BASELINE_PASS=false
|
||||
else
|
||||
BASELINE_PASS=true
|
||||
fi
|
||||
fi
|
||||
|
||||
BASELINE_END=$(date +%s)
|
||||
BASELINE_DURATION=$((BASELINE_END - BASELINE_START))
|
||||
BASELINE_COUNT=$BASELINE_TOTAL
|
||||
|
||||
echo ""
|
||||
echo -e " Duration: ${BASELINE_DURATION}s"
|
||||
if $BASELINE_PASS; then
|
||||
echo -e " ${GREEN}Baseline Tests: PASS${NC}"
|
||||
else
|
||||
echo -e " ${RED}Baseline Tests: FAIL${NC}"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────
|
||||
# Step 3: Architecture Guards
|
||||
# ─────────────────────────────────────────────────────
|
||||
section "Step 3: Architecture Guards"
|
||||
|
||||
GUARD_SCRIPT="$SCRIPT_DIR/guard-all.sh"
|
||||
|
||||
if [ ! -f "$GUARD_SCRIPT" ]; then
|
||||
fail_step "Architecture Guard" "guard-all.sh not found at $GUARD_SCRIPT" "Ensure PR-B guard scripts exist"
|
||||
GUARD_PASS=false
|
||||
else
|
||||
echo " Running: bash $GUARD_SCRIPT"
|
||||
echo ""
|
||||
|
||||
# Run guard-all but capture exit code — we need to differentiate HARD vs SOFT
|
||||
# guard-all.sh exits 1 if any hard guard fails
|
||||
GUARD_OUTPUT=$(bash "$GUARD_SCRIPT" 2>&1) || GUARD_EXIT=$?
|
||||
|
||||
echo "$GUARD_OUTPUT"
|
||||
|
||||
# Parse guard-all output for individual guard statuses
|
||||
GUARD_TOTAL=6
|
||||
GUARD_PASSED=0
|
||||
GUARD_FAILED=0
|
||||
GUARD_WARNED=0
|
||||
|
||||
while IFS='|' read -r name result; do
|
||||
case "$result" in
|
||||
PASS) GUARD_PASSED=$((GUARD_PASSED + 1)) ;;
|
||||
FAIL) GUARD_FAILED=$((GUARD_FAILED + 1)) ;;
|
||||
WARN) GUARD_WARNED=$((GUARD_WARNED + 1)) ;;
|
||||
esac
|
||||
done < <(echo "$GUARD_OUTPUT" | grep -E '^\S+\|(PASS|FAIL|WARN)$' || true)
|
||||
|
||||
# HARD guard failure = production check failure
|
||||
# SOFT guard warning ≠ production check failure
|
||||
if [ "${GUARD_EXIT:-0}" -ne 0 ]; then
|
||||
fail_step "Architecture Guard" "$GUARD_FAILED HARD guard(s) failed" "Review guard output above and fix violations"
|
||||
GUARD_PASS=false
|
||||
else
|
||||
GUARD_PASS=true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Guard status: $GUARD_PASSED pass | $GUARD_FAILED fail | $GUARD_WARNED warn"
|
||||
|
||||
if $GUARD_PASS; then
|
||||
echo -e " ${GREEN}Architecture Guard: PASS${NC}"
|
||||
else
|
||||
echo -e " ${RED}Architecture Guard: FAIL${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────
|
||||
# Step 4: Deprecation Check
|
||||
# ─────────────────────────────────────────────────────
|
||||
section "Step 4: Deprecation Check"
|
||||
|
||||
DEPRECATION_SCRIPT="$SCRIPT_DIR/check-deprecations.mjs"
|
||||
|
||||
if [ ! -f "$DEPRECATION_SCRIPT" ]; then
|
||||
DEPRECATION_STATUS="FAIL"
|
||||
fail_step "Deprecation Check" "check-deprecations.mjs not found" "Ensure PR-C deprecation check script exists"
|
||||
else
|
||||
echo " Running: node $DEPRECATION_SCRIPT"
|
||||
echo ""
|
||||
|
||||
# Deprecation check never fails on deprecation count, only on script crash
|
||||
DEPRECATION_OUTPUT=$(node "$DEPRECATION_SCRIPT" 2>&1) || DEP_EXIT=$?
|
||||
|
||||
echo "$DEPRECATION_OUTPUT"
|
||||
|
||||
# Extract deprecation count and list
|
||||
DEPRECATION_COUNT=$(echo "$DEPRECATION_OUTPUT" | grep "Total warnings:" | grep -oE '[0-9]+' || echo "0")
|
||||
|
||||
if [ "${DEP_EXIT:-0}" -ne 0 ]; then
|
||||
DEPRECATION_STATUS="FAIL"
|
||||
fail_step "Deprecation Check" "Script crashed (exit $DEP_EXIT)" "Check $DEPRECATION_SCRIPT for errors"
|
||||
elif [ "$DEPRECATION_COUNT" -gt 0 ]; then
|
||||
DEPRECATION_STATUS="WARN"
|
||||
else
|
||||
DEPRECATION_STATUS="PASS"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Deprecation items: $DEPRECATION_COUNT"
|
||||
|
||||
# Print deprecation list if any
|
||||
DEP_LIST=$(echo "$DEPRECATION_OUTPUT" | sed -n '/Deprecated modules:/,/^$/p' || true)
|
||||
if [ -n "$DEP_LIST" ]; then
|
||||
echo "$DEP_LIST"
|
||||
fi
|
||||
|
||||
case "$DEPRECATION_STATUS" in
|
||||
PASS) echo -e " ${GREEN}Deprecation Check: PASS${NC}" ;;
|
||||
WARN) echo -e " ${YELLOW}Deprecation Check: WARN${NC}" ;;
|
||||
FAIL) echo -e " ${RED}Deprecation Check: FAIL${NC}" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────
|
||||
# Step 5: Smoke Test
|
||||
# ─────────────────────────────────────────────────────
|
||||
section "Step 5: Smoke Test"
|
||||
|
||||
SMOKE_SCRIPT="$SCRIPT_DIR/smoke-test.mjs"
|
||||
|
||||
if [ ! -f "$SMOKE_SCRIPT" ]; then
|
||||
fail_step "Smoke Test" "smoke-test.mjs not found at $SMOKE_SCRIPT" "Create scripts/smoke-test.mjs"
|
||||
SMOKE_PASS=false
|
||||
else
|
||||
echo " Running: node $SMOKE_SCRIPT"
|
||||
echo ""
|
||||
|
||||
SMOKE_OUTPUT=$(node "$SMOKE_SCRIPT" 2>&1) || SMOKE_EXIT=$?
|
||||
|
||||
echo "$SMOKE_OUTPUT"
|
||||
|
||||
if [ "${SMOKE_EXIT:-0}" -eq 0 ]; then
|
||||
SMOKE_PASS=true
|
||||
echo -e " ${GREEN}Smoke Test: PASS${NC}"
|
||||
else
|
||||
SMOKE_PASS=false
|
||||
fail_step "Smoke Test" "Smoke test failed (exit $SMOKE_EXIT)" "Check smoke test output above"
|
||||
echo -e " ${RED}Smoke Test: FAIL${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────
|
||||
# Step 6: Summary Report
|
||||
# ─────────────────────────────────────────────────────
|
||||
END_TIME=$(date +%s)
|
||||
TOTAL_DURATION=$((END_TIME - START_TIME))
|
||||
|
||||
section "Step 6: Summary Report"
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}OpenClaw Production Check Summary${NC}"
|
||||
echo -e " ────────────────────────────────"
|
||||
|
||||
# Print each step status
|
||||
print_status() {
|
||||
local label="$1" status="$2"
|
||||
case "$status" in
|
||||
PASS) echo -e " ${label}: ${GREEN}PASS${NC}" ;;
|
||||
FAIL) echo -e " ${label}: ${RED}FAIL${NC}" ;;
|
||||
WARN) echo -e " ${label}: ${YELLOW}WARN${NC}" ;;
|
||||
*) echo -e " ${label}: $status" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
ENV_STR=$($ENV_PASS && echo "PASS" || echo "FAIL")
|
||||
BASELINE_STR=$($BASELINE_PASS && echo "PASS" || echo "FAIL")
|
||||
GUARD_STR=$($GUARD_PASS && echo "PASS" || echo "FAIL")
|
||||
|
||||
print_status "Environment" "$ENV_STR"
|
||||
print_status "Baseline Tests" "$BASELINE_STR"
|
||||
print_status "Architecture Guard" "$GUARD_STR"
|
||||
print_status "Deprecation Check" "$DEPRECATION_STATUS"
|
||||
print_status "Smoke Test" "$($SMOKE_PASS && echo "PASS" || echo "FAIL")"
|
||||
|
||||
echo ""
|
||||
OVERALL_STR=$($OVERALL_PASS && echo "PASS" || echo "FAIL")
|
||||
echo -e " ${BOLD}Overall:${NC} $($OVERALL_PASS && echo -e "${GREEN}PASS${NC}" || echo -e "${RED}FAIL${NC}")"
|
||||
echo -e " ${BOLD}Duration:${NC} ${TOTAL_DURATION}s"
|
||||
|
||||
# Failure details
|
||||
if ! $OVERALL_PASS; then
|
||||
echo ""
|
||||
echo -e " ${RED}${BOLD}Failed Step:${NC} ${FAILED_STEP}"
|
||||
echo -e " ${RED}${BOLD}Reason:${NC} ${FAIL_REASON}"
|
||||
echo -e " ${YELLOW}${BOLD}Suggested Fix:${NC} ${FAIL_FIX}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
|
||||
|
||||
if $OVERALL_PASS; then
|
||||
echo -e "${GREEN} ✓ PRODUCTION CHECK PASSED — Safe to proceed${NC}"
|
||||
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED} ✗ PRODUCTION CHECK FAILED — Fix before proceeding${NC}"
|
||||
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Regression Gate — SF-08
|
||||
*
|
||||
* 执行所有验证 gate,全部通过才允许发布。
|
||||
* Exit 0 = PASS, Exit 1 = FAIL.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/regression-gate.mjs [--strict]
|
||||
*
|
||||
* Gates:
|
||||
* 1. 10 Domain E2E Benchmark
|
||||
* 2. 170 Regression Tests
|
||||
* 3. 5 Domain Semantic Spot-Check
|
||||
*
|
||||
* @module regression-gate
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(resolve(fileURLToPath(import.meta.url)));
|
||||
const WORKSPACE = resolve(__dirname, "..");
|
||||
|
||||
const STRICT = process.argv.includes("--strict");
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Gate Definitions
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
const SEMANTIC_SPOTCHECK = [
|
||||
{ name: "BookShelf", input: "做一个图书管理系统,支持书籍库、阅读状态、阅读进度、书评和统计", expectTables: ["books", "reading_status"] },
|
||||
{ name: "TeamFlow", input: "做一个团队协作工具,支持看板视图、任务管理、团队成员和活动日志", expectTables: ["boards", "tasks"] },
|
||||
{ name: "Contract", input: "做一个合同管理工具,支持合同创建、审批流程、到期提醒和合同归档", expectTables: ["contracts", "approvals"] },
|
||||
{ name: "Inspection", input: "做一个设备巡检系统,支持巡检计划、巡检记录、故障上报和设备台账", expectTables: ["equipment", "faults"] },
|
||||
{ name: "Warehouse", input: "做一个仓库出入库工具,支持入库登记、出库审批、库存盘点和库存预警", expectTables: ["stock_in", "stock_out"] },
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function run(cmd, label) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const out = execSync(cmd, { cwd: WORKSPACE, encoding: "utf8", stdio: "pipe", timeout: 180_000 });
|
||||
const ms = Date.now() - start;
|
||||
return { pass: true, ms, output: out };
|
||||
} catch (e) {
|
||||
const ms = Date.now() - start;
|
||||
return { pass: false, ms, output: e.stderr || e.stdout || e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function gateHeader(n, label) {
|
||||
console.log(`\n${"═".repeat(60)}`);
|
||||
console.log(` GATE ${n}: ${label}`);
|
||||
console.log(`${"═".repeat(60)}`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Gate 1: 10 Domain E2E Benchmark
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function gateBenchmark() {
|
||||
gateHeader(1, "10 Domain E2E Benchmark");
|
||||
const r = run("node scripts/e2e-benchmark-v1.mjs", "benchmark");
|
||||
const passMatch = r.output.match(/(\d+)\/(\d+) PASS/);
|
||||
const passCount = passMatch ? parseInt(passMatch[1]) : 0;
|
||||
const total = passMatch ? parseInt(passMatch[2]) : 0;
|
||||
const pass = passCount === 10 && total === 10;
|
||||
console.log(` Result: ${passCount}/${total} PASS (${r.ms}ms)`);
|
||||
console.log(` Status: ${pass ? "✅ PASS" : "❌ FAIL"}`);
|
||||
return pass;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Gate 2: 170 Regression Tests
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function gateRegression() {
|
||||
gateHeader(2, "170 Regression Tests");
|
||||
const r = run("node --test test/e2e-regression.test.mjs", "regression");
|
||||
const passMatch = r.output.match(/pass (\d+)/);
|
||||
const failMatch = r.output.match(/fail (\d+)/);
|
||||
const passCount = passMatch ? parseInt(passMatch[1]) : 0;
|
||||
const failCount = failMatch ? parseInt(failMatch[1]) : 0;
|
||||
const pass = passCount === 170 && failCount === 0;
|
||||
console.log(` Result: ${passCount} pass, ${failCount} fail (${r.ms}ms)`);
|
||||
console.log(` Status: ${pass ? "✅ PASS" : "❌ FAIL"}`);
|
||||
return pass;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Gate 3: Semantic Spot-Check
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function gateSemantic() {
|
||||
gateHeader(3, "5 Domain Semantic Spot-Check");
|
||||
let allPass = true;
|
||||
|
||||
for (const tc of SEMANTIC_SPOTCHECK) {
|
||||
const tmpDir = `/tmp/gate-${tc.name.toLowerCase()}`;
|
||||
try {
|
||||
execSync(`node scripts/project-intake-agent.mjs --input "${tc.input}" --output ${tmpDir}/prd.json`, { cwd: WORKSPACE, encoding: "utf8", stdio: "pipe" });
|
||||
execSync(`node scripts/architecture-agent.mjs --input ${tmpDir}/prd.json --output ${tmpDir}/arch.json`, { cwd: WORKSPACE, encoding: "utf8", stdio: "pipe" });
|
||||
|
||||
const arch = JSON.parse(readFileSync(`${tmpDir}/arch.json`, "utf8"));
|
||||
const tables = arch.databaseSchema.map(t => t.table);
|
||||
const hasChinese = tables.some(t => /[\u4e00-\u9fff]/.test(t));
|
||||
const hasExpected = tc.expectTables.every(et => tables.includes(et));
|
||||
const hasDomainFields = arch.databaseSchema.filter(t => t.table !== "users").some(t => {
|
||||
const fields = t.fields.filter(f => !["id", "user_id", "created_at", "updated_at"].includes(f.name));
|
||||
return fields.some(f => !["title", "description", "status", "data"].includes(f.name));
|
||||
});
|
||||
|
||||
const pass = !hasChinese && hasExpected && hasDomainFields;
|
||||
console.log(` ${pass ? "✅" : "❌"} ${tc.name}: tables=[${tables.join(", ")}]${hasChinese ? " [CHINESE!]" : ""}${!hasExpected ? " [MISSING TABLES!]" : ""}${!hasDomainFields ? " [GENERIC FIELDS!]" : ""}`);
|
||||
if (!pass) allPass = false;
|
||||
} catch (e) {
|
||||
console.log(` ❌ ${tc.name}: ${e.message.split("\n")[0]}`);
|
||||
allPass = false;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n Status: ${allPass ? "✅ PASS" : "❌ FAIL"}`);
|
||||
return allPass;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Main
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
async function main() {
|
||||
console.log("🔒 Regression Gate — v1.1.0-semantic");
|
||||
console.log(` Mode: ${STRICT ? "STRICT" : "NORMAL"}`);
|
||||
console.log(` Time: ${new Date().toISOString()}`);
|
||||
|
||||
const results = {
|
||||
benchmark: gateBenchmark(),
|
||||
regression: gateRegression(),
|
||||
semantic: gateSemantic(),
|
||||
};
|
||||
|
||||
console.log(`\n${"═".repeat(60)}`);
|
||||
console.log(" SUMMARY");
|
||||
console.log(`${"═".repeat(60)}`);
|
||||
for (const [name, pass] of Object.entries(results)) {
|
||||
console.log(` ${pass ? "✅" : "❌"} ${name}`);
|
||||
}
|
||||
|
||||
const allPass = Object.values(results).every(Boolean);
|
||||
console.log(`\n ${allPass ? "🟢 ALL GATES PASS — Release approved" : "🔴 GATE FAILED — Release blocked"}`);
|
||||
console.log("");
|
||||
|
||||
process.exit(allPass ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error("Gate runner crashed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Release Benchmark — SF-07
|
||||
*
|
||||
* 验证完整链路:Frontend → Backend → Fullstack → Electron → Release
|
||||
* 对 PetCare / CRM / Inventory 三个领域执行,输出 release-benchmark-report.md
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(resolve(fileURLToPath(import.meta.url)));
|
||||
const WORKSPACE = resolve(__dirname, "..");
|
||||
const BENCH_ROOT = resolve(WORKSPACE, ".benchmark");
|
||||
const SCRIPTS = resolve(WORKSPACE, "scripts");
|
||||
const REPORT_PATH = resolve(WORKSPACE, "release-benchmark-report.md");
|
||||
|
||||
const DOMAINS = [
|
||||
{ id: "petcare", input: "做一个宠物护理管理平台,宠物主人可以管理宠物档案、健康日程、日常记录和成长相册" },
|
||||
{ id: "crm", input: "做一个客户关系管理系统,支持客户管理、销售漏斗、跟进记录和数据分析仪表盘" },
|
||||
{ id: "inventory", input: "做一个库存管理系统,支持商品入库出库、库存盘点、供应商管理和库存预警" },
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function cmd(c, opts = {}) {
|
||||
try {
|
||||
return execSync(c, { cwd: WORKSPACE, encoding: "utf8", ...opts, stdio: opts.stdio ?? "pipe" });
|
||||
} catch (e) {
|
||||
return { error: e.message, stderr: e.stderr?.toString() || "", stdout: e.stdout?.toString() || "" };
|
||||
}
|
||||
}
|
||||
|
||||
function runAgent(agentPath, args) {
|
||||
const result = cmd(`node ${agentPath} ${args}`);
|
||||
if (result.error) {
|
||||
try { return JSON.parse(result.stdout?.trim() || "{}"); } catch { return { error: result.error, stderr: result.stderr }; }
|
||||
}
|
||||
try { return JSON.parse(result.trim()); } catch { return { raw: result }; }
|
||||
}
|
||||
|
||||
function countFiles(dir) {
|
||||
try {
|
||||
const r = execSync(`find ${dir} -type f 2>/dev/null | wc -l`, { encoding: "utf8", cwd: WORKSPACE });
|
||||
return parseInt(r.trim(), 10);
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
function loadJSON(path) {
|
||||
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return {}; }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Step 1-4: Full Pipeline (reuse existing agents)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateRequirementPackage(domain) {
|
||||
console.log(`\n📋 [${domain.id}] Generating RequirementPackage...`);
|
||||
const t0 = Date.now();
|
||||
|
||||
const prdOut = join(BENCH_ROOT, domain.id, "prd.json");
|
||||
const archOut = join(BENCH_ROOT, domain.id, "arch.json");
|
||||
mkdirSync(dirname(prdOut), { recursive: true });
|
||||
|
||||
runAgent(join(SCRIPTS, "project-intake-agent.mjs"), `--input "${domain.input}" --output "${prdOut}"`);
|
||||
runAgent(join(SCRIPTS, "architecture-agent.mjs"), `--input "${prdOut}" --output "${archOut}"`);
|
||||
|
||||
const prd = loadJSON(prdOut);
|
||||
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
console.log(` ✅ PRD + Architecture (${elapsed}s) — ${prd.projectName || "?"}`);
|
||||
return { prd, prdPath: prdOut, archPath: archOut, elapsed: parseFloat(elapsed) };
|
||||
}
|
||||
|
||||
function generateFrontend(domain, prdPath, archPath) {
|
||||
console.log(` 🎨 Generating Frontend...`);
|
||||
const t0 = Date.now();
|
||||
const outDir = join(BENCH_ROOT, domain.id, "frontend");
|
||||
runAgent(join(SCRIPTS, "frontend-builder-agent.mjs"), `--prd "${prdPath}" --arch "${archPath}" --output "${outDir}"`);
|
||||
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
const fc = countFiles(outDir);
|
||||
console.log(` ✅ Frontend (${elapsed}s, ${fc} files)`);
|
||||
return { elapsed: parseFloat(elapsed), fileCount: fc };
|
||||
}
|
||||
|
||||
function generateBackend(domain, prdPath, archPath) {
|
||||
console.log(` ⚙️ Generating Backend...`);
|
||||
const t0 = Date.now();
|
||||
const outDir = join(BENCH_ROOT, domain.id, "backend");
|
||||
runAgent(join(SCRIPTS, "backend-builder-agent.mjs"), `--prd "${prdPath}" --arch "${archPath}" --output "${outDir}"`);
|
||||
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
const fc = countFiles(outDir);
|
||||
console.log(` ✅ Backend (${elapsed}s, ${fc} files)`);
|
||||
return { elapsed: parseFloat(elapsed), fileCount: fc };
|
||||
}
|
||||
|
||||
function generateFullstack(domain, prdPath, archPath) {
|
||||
console.log(` 🔗 Composing Fullstack...`);
|
||||
const t0 = Date.now();
|
||||
const outDir = join(BENCH_ROOT, domain.id, "fullstack");
|
||||
runAgent(join(SCRIPTS, "fullstack-composer-agent.mjs"), `--prd "${prdPath}" --arch "${archPath}" --output "${outDir}"`);
|
||||
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
const fc = countFiles(outDir);
|
||||
console.log(` ✅ Fullstack (${elapsed}s, ${fc} files)`);
|
||||
return { elapsed: parseFloat(elapsed), fileCount: fc };
|
||||
}
|
||||
|
||||
function generateElectron(domain, prdPath) {
|
||||
console.log(` 🖥️ Generating Electron...`);
|
||||
const t0 = Date.now();
|
||||
const fullstackDir = join(BENCH_ROOT, domain.id, "fullstack");
|
||||
const outDir = join(BENCH_ROOT, domain.id, "electron");
|
||||
runAgent(join(SCRIPTS, "electron-builder-agent.mjs"), `--input "${fullstackDir}" --output "${outDir}" --prd "${prdPath}"`);
|
||||
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
const fc = countFiles(outDir);
|
||||
console.log(` ✅ Electron (${elapsed}s, ${fc} files)`);
|
||||
return { elapsed: parseFloat(elapsed), fileCount: fc };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Step 5: Release Builder
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateRelease(domain) {
|
||||
console.log(` 📦 Generating Release...`);
|
||||
const t0 = Date.now();
|
||||
const fullstackDir = join(BENCH_ROOT, domain.id, "fullstack");
|
||||
const outDir = join(BENCH_ROOT, domain.id, "release");
|
||||
|
||||
const result = runAgent(join(SCRIPTS, "release-builder-agent.mjs"), `--input "${fullstackDir}" --output "${outDir}"`);
|
||||
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
const fc = countFiles(outDir);
|
||||
console.log(` ✅ Release (${elapsed}s, ${fc} files)`);
|
||||
return { elapsed: parseFloat(elapsed), fileCount: fc, result };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Validation
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function validateRelease(domain) {
|
||||
const releaseDir = join(BENCH_ROOT, domain.id, "release");
|
||||
const checks = {
|
||||
hasVersionJson: existsSync(join(releaseDir, "release", "version.json")),
|
||||
hasManifestJson: existsSync(join(releaseDir, "release", "manifests", "manifest.json")),
|
||||
hasChecksums: existsSync(join(releaseDir, "release", "checksums", "checksums.txt")),
|
||||
hasReleaseNotes: existsSync(join(releaseDir, "release", "release-notes", "release-notes.md")),
|
||||
hasBuildInfo: existsSync(join(releaseDir, "release", "build-info.json")),
|
||||
hasReadme: existsSync(join(releaseDir, "release", "README.md")),
|
||||
hasWindowsDir: existsSync(join(releaseDir, "release", "windows")),
|
||||
hasMacosDir: existsSync(join(releaseDir, "release", "macos")),
|
||||
hasLinuxDir: existsSync(join(releaseDir, "release", "linux")),
|
||||
};
|
||||
|
||||
// Validate version.json
|
||||
if (checks.hasVersionJson) {
|
||||
try {
|
||||
const v = JSON.parse(readFileSync(join(releaseDir, "release", "version.json"), "utf8"));
|
||||
checks.versionValid = !!(v.name && v.version && v.platforms);
|
||||
} catch { checks.versionValid = false; }
|
||||
}
|
||||
|
||||
// Validate manifest
|
||||
if (checks.hasManifestJson) {
|
||||
try {
|
||||
const m = JSON.parse(readFileSync(join(releaseDir, "release", "manifests", "manifest.json"), "utf8"));
|
||||
checks.manifestValid = !!(m.project && m.version && m.files && m.files.length > 0);
|
||||
checks.manifestFileCount = m.files?.length || 0;
|
||||
} catch { checks.manifestValid = false; }
|
||||
}
|
||||
|
||||
// Validate checksums
|
||||
if (checks.hasChecksums) {
|
||||
const content = readFileSync(join(releaseDir, "release", "checksums", "checksums.txt"), "utf8");
|
||||
const lines = content.trim().split("\n").filter(l => l.length > 0);
|
||||
checks.checksumCount = lines.length;
|
||||
checks.checksumsValid = lines.every(l => {
|
||||
const parts = l.split(/\s+/);
|
||||
return parts[0]?.length === 64 && parts[1]?.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Validate release notes
|
||||
if (checks.hasReleaseNotes) {
|
||||
const notes = readFileSync(join(releaseDir, "release", "release-notes", "release-notes.md"), "utf8");
|
||||
checks.releaseNotesValid = notes.includes("Version") && notes.includes("Features") && notes.includes("Installation");
|
||||
}
|
||||
|
||||
// Validate build-info
|
||||
if (checks.hasBuildInfo) {
|
||||
try {
|
||||
const bi = JSON.parse(readFileSync(join(releaseDir, "release", "build-info.json"), "utf8"));
|
||||
checks.buildInfoValid = !!(bi.nodeVersion && bi.generatorVersion && bi.buildTime);
|
||||
} catch { checks.buildInfoValid = false; }
|
||||
}
|
||||
|
||||
// Platform placeholder counts
|
||||
if (checks.hasWindowsDir) {
|
||||
try { checks.windowsFileCount = readdirSync(join(releaseDir, "release", "windows")).length; } catch { checks.windowsFileCount = 0; }
|
||||
}
|
||||
if (checks.hasMacosDir) {
|
||||
try { checks.macosFileCount = readdirSync(join(releaseDir, "release", "macos")).length; } catch { checks.macosFileCount = 0; }
|
||||
}
|
||||
if (checks.hasLinuxDir) {
|
||||
try { checks.linuxFileCount = readdirSync(join(releaseDir, "release", "linux")).length; } catch { checks.linuxFileCount = 0; }
|
||||
}
|
||||
|
||||
checks.allPassed = checks.hasVersionJson && checks.hasManifestJson && checks.hasChecksums &&
|
||||
checks.hasReleaseNotes && checks.hasBuildInfo && checks.hasWindowsDir &&
|
||||
checks.hasMacosDir && checks.hasLinuxDir && checks.versionValid &&
|
||||
checks.manifestValid && checks.checksumsValid && checks.releaseNotesValid && checks.buildInfoValid;
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Report
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateReport(allResults) {
|
||||
const lines = [];
|
||||
const h = (t) => { lines.push(t); return lines; };
|
||||
|
||||
h(`# Release Benchmark Report — SF-07`);
|
||||
h(``);
|
||||
h(`> Generated: ${new Date().toISOString()}`);
|
||||
h(`> Pipeline: Frontend → Backend → Fullstack → Electron → Release`);
|
||||
h(``);
|
||||
|
||||
// ── Domain Results ──
|
||||
h(`## Domain Results`);
|
||||
h(``);
|
||||
h(`| Domain | Release | Manifest | Notes | Result |`);
|
||||
h(`|--------|---------|----------|-------|--------|`);
|
||||
|
||||
for (const r of allResults) {
|
||||
const rel = r.releaseChecks?.allPassed ? "✅" : "❌";
|
||||
const man = r.releaseChecks?.manifestValid ? "✅" : "❌";
|
||||
const notes = r.releaseChecks?.releaseNotesValid ? "✅" : "❌";
|
||||
const result = r.releaseChecks?.allPassed ? "PASS" : "FAIL";
|
||||
h(`| ${r.id} | ${rel} | ${man} | ${notes} | **${result}** |`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
// ── Release Artifacts ──
|
||||
h(`## Release Artifacts`);
|
||||
h(``);
|
||||
h(`| Domain | Files | Manifest Files | Checksum Lines | Release Notes |`);
|
||||
h(`|--------|-------|----------------|----------------|---------------|`);
|
||||
|
||||
for (const r of allResults) {
|
||||
const rc = r.releaseChecks || {};
|
||||
h(`| ${r.id} | ${r.release?.fileCount || 0} | ${rc.manifestFileCount || 0} | ${rc.checksumCount || 0} | ${rc.hasReleaseNotes ? "✅" : "❌"} |`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
// ── Pipeline Timing ──
|
||||
h(`## Pipeline Timing`);
|
||||
h(``);
|
||||
h(`| Domain | Frontend | Backend | Fullstack | Electron | Release | Total |`);
|
||||
h(`|--------|----------|---------|-----------|----------|---------|-------|`);
|
||||
|
||||
for (const r of allResults) {
|
||||
h(`| ${r.id} | ${r.frontend?.elapsed || 0}s | ${r.backend?.elapsed || 0}s | ${r.fullstack?.elapsed || 0}s | ${r.electron?.elapsed || 0}s | ${r.release?.elapsed || 0}s | ${r.totalTime || 0}s |`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
// ── Validation Details ──
|
||||
h(`## Validation Details`);
|
||||
h(``);
|
||||
|
||||
for (const r of allResults) {
|
||||
h(`### ${r.id}`);
|
||||
h(``);
|
||||
const rc = r.releaseChecks || {};
|
||||
h(`| Check | Status |`);
|
||||
h(`|-------|--------|`);
|
||||
h(`| version.json | ${rc.hasVersionJson ? "✅" : "❌"} |`);
|
||||
h(`| version.json valid | ${rc.versionValid ? "✅" : "❌"} |`);
|
||||
h(`| manifest.json | ${rc.hasManifestJson ? "✅" : "❌"} |`);
|
||||
h(`| manifest valid | ${rc.manifestValid ? "✅" : "❌"} |`);
|
||||
h(`| checksums.txt | ${rc.hasChecksums ? "✅" : "❌"} |`);
|
||||
h(`| checksums valid | ${rc.checksumsValid ? "✅" : "❌"} |`);
|
||||
h(`| release-notes.md | ${rc.hasReleaseNotes ? "✅" : "❌"} |`);
|
||||
h(`| release notes valid | ${rc.releaseNotesValid ? "✅" : "❌"} |`);
|
||||
h(`| build-info.json | ${rc.hasBuildInfo ? "✅" : "❌"} |`);
|
||||
h(`| build-info valid | ${rc.buildInfoValid ? "✅" : "❌"} |`);
|
||||
h(`| windows/ | ${rc.hasWindowsDir ? "✅" : "❌"} (${rc.windowsFileCount || 0} files) |`);
|
||||
h(`| macos/ | ${rc.hasMacosDir ? "✅" : "❌"} (${rc.macosFileCount || 0} files) |`);
|
||||
h(`| linux/ | ${rc.hasLinuxDir ? "✅" : "❌"} (${rc.linuxFileCount || 0} files) |`);
|
||||
h(``);
|
||||
}
|
||||
|
||||
// ── Final Status ──
|
||||
h(`## Final Status`);
|
||||
h(``);
|
||||
|
||||
const passCount = allResults.filter(r => r.releaseChecks?.allPassed).length;
|
||||
const total = allResults.length;
|
||||
|
||||
h(`| Metric | Value |`);
|
||||
h(`|--------|-------|`);
|
||||
h(`| Domains Tested | ${total} |`);
|
||||
h(`| Release PASS | ${passCount}/${total} |`);
|
||||
h(`| Release FAIL | ${total - passCount}/${total} |`);
|
||||
h(``);
|
||||
|
||||
if (passCount === total) {
|
||||
h(`🎉 **Release Builder Agent — PASS** (${passCount}/${total})`);
|
||||
h(``);
|
||||
h(`All domains successfully generated complete release packages.`);
|
||||
} else {
|
||||
h(`⚠️ **Release Builder Agent — FAIL** (${passCount}/${total})`);
|
||||
h(``);
|
||||
const failed = allResults.filter(r => !r.releaseChecks?.allPassed);
|
||||
h(`Failed domains: ${failed.map(r => r.id).join(", ")}`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
h(`---`);
|
||||
h(`*Report generated by Release Benchmark — SF-07*`);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Main
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
async function main() {
|
||||
console.log(`\n🧪 Release Benchmark — SF-07`);
|
||||
console.log(` Testing ${DOMAINS.length} domains`);
|
||||
console.log(` Pipeline: Frontend → Backend → Fullstack → Electron → Release\n`);
|
||||
|
||||
const allResults = [];
|
||||
|
||||
for (const domain of DOMAINS) {
|
||||
console.log(`\n${"=".repeat(60)}`);
|
||||
console.log(`🏗️ Domain: ${domain.id}`);
|
||||
console.log(`${"=".repeat(60)}`);
|
||||
|
||||
const tTotal = Date.now();
|
||||
|
||||
// Steps 1-4: Generate full pipeline
|
||||
const { prd, prdPath, archPath, elapsed: reqTime } = generateRequirementPackage(domain);
|
||||
const frontend = generateFrontend(domain, prdPath, archPath);
|
||||
const backend = generateBackend(domain, prdPath, archPath);
|
||||
const fullstack = generateFullstack(domain, prdPath, archPath);
|
||||
const electron = generateElectron(domain, prdPath);
|
||||
|
||||
// Step 5: Release
|
||||
const release = generateRelease(domain);
|
||||
|
||||
// Validate
|
||||
const releaseChecks = validateRelease(domain);
|
||||
|
||||
const totalTime = ((Date.now() - tTotal) / 1000).toFixed(1);
|
||||
|
||||
allResults.push({
|
||||
id: domain.id,
|
||||
input: domain.input,
|
||||
projectName: prd.projectName,
|
||||
reqTime,
|
||||
frontend,
|
||||
backend,
|
||||
fullstack,
|
||||
electron,
|
||||
release,
|
||||
releaseChecks,
|
||||
totalTime: parseFloat(totalTime),
|
||||
});
|
||||
|
||||
console.log(` ⏱️ Total: ${totalTime}s | Release: ${releaseChecks.allPassed ? "✅ PASS" : "❌ FAIL"}`);
|
||||
}
|
||||
|
||||
// Generate report
|
||||
const report = generateReport(allResults);
|
||||
writeFileSync(REPORT_PATH, report, "utf8");
|
||||
console.log(`\n📄 Report written to: ${REPORT_PATH}`);
|
||||
|
||||
// Summary
|
||||
const passCount = allResults.filter(r => r.releaseChecks?.allPassed).length;
|
||||
console.log(`\n📊 Summary: ${passCount}/${DOMAINS.length} domains PASS`);
|
||||
console.log(` Release Builder: ${passCount === DOMAINS.length ? "✅ PASS" : "❌ FAIL"}`);
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error("Benchmark failed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,542 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Release Builder Agent — SF-07
|
||||
*
|
||||
* 将 SF-06 Electron Project 封装为可发布软件包结构。
|
||||
*
|
||||
* 职责:Electron Project → Release Package Structure
|
||||
* 不做:自动更新、CI/CD、Docker、云发布、安装器、签名、证书
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/release-builder-agent.mjs --input <electron-dir> [options]
|
||||
*
|
||||
* @module release-builder-agent
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const __dirname = dirname(resolve(fileURLToPath(import.meta.url)));
|
||||
const WORKSPACE = resolve(__dirname, "..");
|
||||
|
||||
const GENERATOR_VERSION = "1.0.0";
|
||||
const PLATFORMS = ["windows", "macos", "linux"];
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 1. Metadata Extraction
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
|
||||
|
||||
function extractMetadataSync(projectDir) {
|
||||
const meta = {
|
||||
projectName: "app",
|
||||
version: "1.0.0",
|
||||
buildNumber: 1,
|
||||
domain: "unknown",
|
||||
entities: [],
|
||||
routes: [],
|
||||
screens: [],
|
||||
};
|
||||
|
||||
// Read root package.json
|
||||
const rootPkgPath = join(projectDir, "package.json");
|
||||
if (existsSync(rootPkgPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(rootPkgPath, "utf8"));
|
||||
meta.projectName = pkg.name || meta.projectName;
|
||||
meta.version = pkg.version || meta.version;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Read desktop package.json
|
||||
const desktopPkgPath = join(projectDir, "apps", "desktop", "package.json");
|
||||
if (existsSync(desktopPkgPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(desktopPkgPath, "utf8"));
|
||||
if (pkg.name) meta.projectName = pkg.name.replace(/-desktop$/, "");
|
||||
if (pkg.version) meta.version = pkg.version;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Read PRD if exists
|
||||
const prdPath = join(projectDir, "prd.json");
|
||||
if (existsSync(prdPath)) {
|
||||
try {
|
||||
const prd = JSON.parse(readFileSync(prdPath, "utf8"));
|
||||
if (prd.projectName) meta.projectName = prd.projectName;
|
||||
if (prd.domain) meta.domain = prd.domain;
|
||||
if (prd.features) meta.entities = prd.features.map(f => f.name || f);
|
||||
if (prd.apiRequirements) meta.routes = prd.apiRequirements.map(r => `${r.method} ${r.path}`);
|
||||
if (prd.pages) meta.screens = prd.pages.map(p => p.name || p);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Scan for entities in backend routes
|
||||
const routesDir = join(projectDir, "apps", "api", "src", "routes");
|
||||
if (existsSync(routesDir)) {
|
||||
try {
|
||||
const files = readdirSync(routesDir).filter(f => f.endsWith(".ts") || f.endsWith(".js"));
|
||||
if (meta.entities.length === 0) {
|
||||
meta.entities = files.map(f => f.replace(/\.(ts|js)$/, "").replace(/-route$/, "").replace(/\.route$/, ""));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Scan for screens in frontend
|
||||
const appDir = join(projectDir, "apps", "web", "app");
|
||||
if (existsSync(appDir)) {
|
||||
try {
|
||||
const entries = readdirSync(appDir, { withFileTypes: true });
|
||||
if (meta.screens.length === 0) {
|
||||
meta.screens = entries
|
||||
.filter(e => e.isDirectory() && !e.name.startsWith("_") && !e.name.startsWith("."))
|
||||
.map(e => e.name);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Count API routes from index.ts
|
||||
const apiSrcDir = join(projectDir, "apps", "api", "src");
|
||||
if (existsSync(apiSrcDir) && meta.routes.length === 0) {
|
||||
try {
|
||||
const indexContent = readFileSync(join(apiSrcDir, "index.ts"), "utf8");
|
||||
const routeMatches = indexContent.match(/app\.(use|get|post|put|patch|delete)\s*\(\s*["']([^"']+)/g);
|
||||
if (routeMatches) meta.routes = routeMatches;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 2. Version Generator
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateVersion(meta) {
|
||||
return JSON.stringify({
|
||||
name: meta.projectName.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
||||
version: meta.version,
|
||||
buildNumber: meta.buildNumber,
|
||||
platforms: [...PLATFORMS],
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 3. Release Manifest
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateManifest(meta, artifacts) {
|
||||
const files = [];
|
||||
for (const art of artifacts) {
|
||||
files.push({
|
||||
path: art.path,
|
||||
size: art.size,
|
||||
sha256: art.sha256,
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
project: meta.projectName,
|
||||
version: meta.version,
|
||||
buildNumber: meta.buildNumber,
|
||||
generatedAt: new Date().toISOString(),
|
||||
generator: "release-builder-agent",
|
||||
generatorVersion: GENERATOR_VERSION,
|
||||
platforms: [...PLATFORMS],
|
||||
files,
|
||||
totalFiles: files.length,
|
||||
totalSize: files.reduce((s, f) => s + f.size, 0),
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 4. Checksum Generator
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function sha256(content) {
|
||||
return createHash("sha256").update(typeof content === "string" ? content : content).digest("hex");
|
||||
}
|
||||
|
||||
function generateChecksums(artifacts) {
|
||||
return artifacts.map(a => `${a.sha256} ${a.path}`).join("\n") + "\n";
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 5. Release Notes
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateReleaseNotes(meta) {
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().split("T")[0];
|
||||
const timeStr = now.toISOString().split("T")[1].split(".")[0];
|
||||
|
||||
const lines = [];
|
||||
const h = (t) => { lines.push(t); return lines; };
|
||||
|
||||
h(`# ${meta.projectName} v${meta.version}`);
|
||||
h(``);
|
||||
h(`> Release Builder Agent — SF-07`);
|
||||
h(`> Generated: ${dateStr} ${timeStr} UTC`);
|
||||
h(``);
|
||||
|
||||
h(`## Version`);
|
||||
h(``);
|
||||
h(`- **Name:** ${meta.projectName}`);
|
||||
h(`- **Version:** ${meta.version}`);
|
||||
h(`- **Build:** #${meta.buildNumber}`);
|
||||
h(``);
|
||||
|
||||
h(`## Features`);
|
||||
h(``);
|
||||
if (meta.entities.length > 0) {
|
||||
for (const e of meta.entities) {
|
||||
h(`- ${e}`);
|
||||
}
|
||||
} else {
|
||||
h(`- (No features detected)`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
h(`## Build Info`);
|
||||
h(``);
|
||||
h(`- **Build Time:** ${now.toISOString()}`);
|
||||
h(`- **Generator:** release-builder-agent v${GENERATOR_VERSION}`);
|
||||
h(`- **Node Version:** ${process.version}`);
|
||||
h(``);
|
||||
|
||||
h(`## Platforms`);
|
||||
h(``);
|
||||
for (const p of PLATFORMS) {
|
||||
h(`- ✅ ${p}`);
|
||||
}
|
||||
h(``);
|
||||
|
||||
h(`## Installation`);
|
||||
h(``);
|
||||
h(`### Windows`);
|
||||
h(`\`\`\``);
|
||||
h(`Download the .exe installer from release/windows/`);
|
||||
h(`\`\`\``);
|
||||
h(``);
|
||||
h(`### macOS`);
|
||||
h(`\`\`\``);
|
||||
h(`Download the .dmg from release/macos/`);
|
||||
h(`\`\`\``);
|
||||
h(``);
|
||||
h(`### Linux`);
|
||||
h(`\`\`\``);
|
||||
h(`Download the .AppImage from release/linux/`);
|
||||
h(`\`\`\``);
|
||||
h(``);
|
||||
|
||||
h(`## Checksums`);
|
||||
h(``);
|
||||
h(`See \`checksums/checksums.txt\` for SHA256 verification.`);
|
||||
h(``);
|
||||
|
||||
h(`---`);
|
||||
h(`*Generated by Release Builder Agent — SF-07*`);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 6. Build Metadata
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateBuildInfo(meta) {
|
||||
return JSON.stringify({
|
||||
nodeVersion: process.version,
|
||||
generatorVersion: GENERATOR_VERSION,
|
||||
buildTime: new Date().toISOString(),
|
||||
domain: meta.domain,
|
||||
entityCount: meta.entities.length,
|
||||
routeCount: meta.routes.length,
|
||||
screenCount: meta.screens.length,
|
||||
platforms: [...PLATFORMS],
|
||||
project: meta.projectName,
|
||||
version: meta.version,
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 7. Platform Artifact Layout
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generatePlatformArtifacts(meta) {
|
||||
const safeName = meta.projectName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
||||
const artifacts = {};
|
||||
|
||||
// Windows
|
||||
artifacts[`windows/${safeName}-setup-${meta.version}.exe`] =
|
||||
`[PLACEHOLDER] ${meta.projectName} v${meta.version} Windows Installer\n` +
|
||||
`This is a placeholder file for the Windows NSIS installer.\n` +
|
||||
`Generated: ${new Date().toISOString()}\n`;
|
||||
artifacts[`windows/${safeName}-${meta.version}-portable.exe`] =
|
||||
`[PLACEHOLDER] ${meta.projectName} v${meta.version} Windows Portable\n` +
|
||||
`This is a placeholder file for the Windows portable executable.\n` +
|
||||
`Generated: ${new Date().toISOString()}\n`;
|
||||
|
||||
// macOS
|
||||
artifacts[`macos/${safeName}-${meta.version}.dmg`] =
|
||||
`[PLACEHOLDER] ${meta.projectName} v${meta.version} macOS Disk Image\n` +
|
||||
`This is a placeholder file for the macOS DMG installer.\n` +
|
||||
`Generated: ${new Date().toISOString()}\n`;
|
||||
artifacts[`macos/${safeName}-${meta.version}-arm64.dmg`] =
|
||||
`[PLACEHOLDER] ${meta.projectName} v${meta.version} macOS ARM64 Disk Image\n` +
|
||||
`This is a placeholder file for the macOS ARM64 DMG.\n` +
|
||||
`Generated: ${new Date().toISOString()}\n`;
|
||||
|
||||
// Linux
|
||||
artifacts[`linux/${safeName}-${meta.version}.AppImage`] =
|
||||
`[PLACEHOLDER] ${meta.projectName} v${meta.version} Linux AppImage\n` +
|
||||
`This is a placeholder file for the Linux AppImage.\n` +
|
||||
`Generated: ${new Date().toISOString()}\n`;
|
||||
artifacts[`linux/${safeName}_${meta.version}_amd64.deb`] =
|
||||
`[PLACEHOLDER] ${meta.projectName} v${meta.version} Linux Debian Package\n` +
|
||||
`This is a placeholder file for the Linux .deb package.\n` +
|
||||
`Generated: ${new Date().toISOString()}\n`;
|
||||
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 8. Main Build Function
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Build release package structure for an Electron project.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.projectDir — Electron project root (with apps/web, apps/api, apps/desktop)
|
||||
* @param {string} [options.projectName] — Override project name
|
||||
* @param {string} [options.version] — Override version
|
||||
* @param {number} [options.buildNumber] — Override build number
|
||||
* @returns {object} { files, stats, error, message }
|
||||
*/
|
||||
export function buildRelease(options = {}) {
|
||||
const {
|
||||
projectDir = null,
|
||||
projectName = null,
|
||||
version = null,
|
||||
buildNumber = 1,
|
||||
} = options;
|
||||
|
||||
// Extract metadata from project
|
||||
const meta = projectDir ? extractMetadataSync(projectDir) : {
|
||||
projectName: "app",
|
||||
version: "1.0.0",
|
||||
buildNumber: 1,
|
||||
domain: "unknown",
|
||||
entities: [],
|
||||
routes: [],
|
||||
screens: [],
|
||||
};
|
||||
|
||||
// Apply overrides
|
||||
if (projectName) meta.projectName = projectName;
|
||||
if (version) meta.version = version;
|
||||
meta.buildNumber = buildNumber;
|
||||
|
||||
const files = {};
|
||||
|
||||
// Generate platform artifacts (placeholders)
|
||||
const platformArtifacts = generatePlatformArtifacts(meta);
|
||||
|
||||
// Add platform artifacts to files
|
||||
for (const [path, content] of Object.entries(platformArtifacts)) {
|
||||
files[`release/${path}`] = content;
|
||||
}
|
||||
|
||||
// Compute artifact metadata for manifest
|
||||
const artifactMeta = Object.entries(platformArtifacts).map(([path, content]) => ({
|
||||
path,
|
||||
size: Buffer.byteLength(content, "utf8"),
|
||||
sha256: sha256(content),
|
||||
}));
|
||||
|
||||
// Generate version.json
|
||||
files["release/version.json"] = generateVersion(meta);
|
||||
|
||||
// Generate manifest.json
|
||||
files["release/manifests/manifest.json"] = generateManifest(meta, artifactMeta);
|
||||
|
||||
// Generate checksums.txt
|
||||
files["release/checksums/checksums.txt"] = generateChecksums(artifactMeta);
|
||||
|
||||
// Generate release-notes.md
|
||||
files["release/release-notes/release-notes.md"] = generateReleaseNotes(meta);
|
||||
|
||||
// Generate build-info.json
|
||||
files["release/build-info.json"] = generateBuildInfo(meta);
|
||||
|
||||
// Generate README
|
||||
files["release/README.md"] = generateReadme(meta);
|
||||
|
||||
// Stats
|
||||
const allPaths = Object.keys(files);
|
||||
const stats = {
|
||||
totalFiles: allPaths.length,
|
||||
platformFiles: allPaths.filter(p => p.startsWith("release/windows/") || p.startsWith("release/macos/") || p.startsWith("release/linux/")).length,
|
||||
manifestFiles: allPaths.filter(p => p.includes("manifest")).length,
|
||||
checksumFiles: allPaths.filter(p => p.includes("checksum")).length,
|
||||
releaseNotesFiles: allPaths.filter(p => p.includes("release-notes")).length,
|
||||
metadataFiles: allPaths.filter(p => p.endsWith(".json")).length,
|
||||
totalSize: Object.values(files).reduce((s, c) => s + Buffer.byteLength(c, "utf8"), 0),
|
||||
};
|
||||
|
||||
return { files, stats, meta, error: null, message: null };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 9. README Generator
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function generateReadme(meta) {
|
||||
const lines = [];
|
||||
const h = (t) => { lines.push(t); return lines; };
|
||||
|
||||
h(`# ${meta.projectName} — Release Package`);
|
||||
h(``);
|
||||
h(`> Version: ${meta.version} | Build: #${meta.buildNumber}`);
|
||||
h(`> Generated: ${new Date().toISOString()}`);
|
||||
h(``);
|
||||
|
||||
h(`## Directory Structure`);
|
||||
h(``);
|
||||
h(`\`\`\``);
|
||||
h(`release/`);
|
||||
h(`├── version.json — Version metadata`);
|
||||
h(`├── build-info.json — Build environment info`);
|
||||
h(`├── README.md — This file`);
|
||||
h(`├── manifests/`);
|
||||
h(`│ └── manifest.json — Release manifest with file hashes`);
|
||||
h(`├── checksums/`);
|
||||
h(`│ └── checksums.txt — SHA256 checksums for verification`);
|
||||
h(`├── release-notes/`);
|
||||
h(`│ └── release-notes.md — Human-readable release notes`);
|
||||
h(`├── windows/ — Windows installers`);
|
||||
h(`├── macos/ — macOS disk images`);
|
||||
h(`└── linux/ — Linux packages`);
|
||||
h(`\`\`\``);
|
||||
h(``);
|
||||
|
||||
h(`## Verification`);
|
||||
h(``);
|
||||
h(`\`\`\`bash`);
|
||||
h(`# Verify checksums`);
|
||||
h(`cd release/checksums`);
|
||||
h(`shasum -a 256 -c checksums.txt`);
|
||||
h(`\`\`\``);
|
||||
h(``);
|
||||
|
||||
h(`---`);
|
||||
h(`*Generated by Release Builder Agent — SF-07*`);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 10. File I/O
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
export function writeRelease(result, outputDir) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
for (const [relPath, content] of Object.entries(result.files)) {
|
||||
const fullPath = resolve(outputDir, relPath);
|
||||
mkdirSync(dirname(fullPath), { recursive: true });
|
||||
writeFileSync(fullPath, content, "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 11. CLI Entry
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const opts = {
|
||||
input: null,
|
||||
output: null,
|
||||
projectName: null,
|
||||
version: null,
|
||||
buildNumber: 1,
|
||||
verbose: false,
|
||||
help: false,
|
||||
};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--input" && args[i + 1]) opts.input = args[++i];
|
||||
else if (args[i] === "--output" && args[i + 1]) opts.output = args[++i];
|
||||
else if (args[i] === "--name" && args[i + 1]) opts.projectName = args[++i];
|
||||
else if (args[i] === "--version" && args[i + 1]) opts.version = args[++i];
|
||||
else if (args[i] === "--build-number" && args[i + 1]) opts.buildNumber = parseInt(args[++i], 10);
|
||||
else if (args[i] === "--verbose") opts.verbose = true;
|
||||
else if (args[i] === "--help" || args[i] === "-h") opts.help = true;
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs();
|
||||
|
||||
if (opts.help) {
|
||||
console.log(
|
||||
"\nRelease Builder Agent — SF-07\n\n" +
|
||||
"Generate release package structure for an Electron project.\n\n" +
|
||||
"Usage:\n" +
|
||||
" node scripts/release-builder-agent.mjs --input <electron-dir> [options]\n\n" +
|
||||
"Options:\n" +
|
||||
" --input <dir> Project directory (with apps/web, apps/api, apps/desktop)\n" +
|
||||
" --output <dir> Output directory (default: <input>/release)\n" +
|
||||
" --name <name> Override project name\n" +
|
||||
" --version <ver> Override version\n" +
|
||||
" --build-number <n> Build number (default: 1)\n" +
|
||||
" --verbose Verbose output\n" +
|
||||
" --help Show this help\n"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = buildRelease({
|
||||
projectDir: opts.input,
|
||||
projectName: opts.projectName,
|
||||
version: opts.version,
|
||||
buildNumber: opts.buildNumber,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const outputDir = opts.output
|
||||
? resolve(opts.output)
|
||||
: opts.input
|
||||
? resolve(opts.input, "release")
|
||||
: resolve(WORKSPACE, "release");
|
||||
|
||||
writeRelease(result, outputDir);
|
||||
|
||||
if (opts.verbose) {
|
||||
console.error("Project: " + result.meta.projectName);
|
||||
console.error("Version: " + result.meta.version);
|
||||
console.error("Files: " + result.stats.totalFiles);
|
||||
console.error("Platform files: " + result.stats.platformFiles);
|
||||
console.error("Total size: " + result.stats.totalSize + " bytes");
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
projectName: result.meta.projectName,
|
||||
version: result.meta.version,
|
||||
outputDir,
|
||||
stats: result.stats,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
if (process.argv[1] && (import.meta.url === "file://" + process.argv[1] || import.meta.url.endsWith(process.argv[1]))) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Agent Runtime CLI — PR-35
|
||||
*
|
||||
* Usage:
|
||||
* echo '{"task":{...},"context":{...}}' | node scripts/runtime.mjs
|
||||
*
|
||||
* stdin:
|
||||
* { task, context: { baselineData?, candidateData?, ... }, options? }
|
||||
*
|
||||
* Output: JSON { success, taskResult, report }
|
||||
* Exit: 0=passed, 1=failed/blocked/error
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createTask, createExecutionPlan } from "../src/agent-runtime/domain.mjs";
|
||||
import { executePipeline, recoverTask } from "../src/agent-runtime/pipeline.mjs";
|
||||
import { createRuntimeContext, autoGateAdapter, createCertificationAdapter } from "../src/agent-runtime/adapters.mjs";
|
||||
import { generateRuntimeReport } from "../src/agent-runtime/reporting.mjs";
|
||||
|
||||
function readStdin() {
|
||||
try {
|
||||
const raw = readFileSync(0, "utf8").trim();
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (e) {
|
||||
return { _error: `Parse error: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const input = readStdin();
|
||||
if (!input) { console.error("Error: No stdin input."); process.exit(1); }
|
||||
if (input._error) { console.error(input._error); process.exit(1); }
|
||||
|
||||
const { task: rawTask, context: rawCtx = {}, options = {}, action = "execute" } = input;
|
||||
if (!rawTask) { console.error("Error: 'task' field required."); process.exit(1); }
|
||||
|
||||
// 反序列化 Task
|
||||
const task = createTask({
|
||||
id: rawTask.id, title: rawTask.title || rawTask.id,
|
||||
description: rawTask.description, tags: rawTask.tags,
|
||||
dependsOn: rawTask.dependsOn, parentTaskId: rawTask.parentTaskId,
|
||||
metadata: rawTask.metadata,
|
||||
});
|
||||
task.executionPlan = createExecutionPlan({
|
||||
taskId: task.id,
|
||||
steps: (rawTask.steps || []).map(s => ({
|
||||
id: s.id, name: s.name || s.id, action: s.action || s.name,
|
||||
dependsOn: s.dependsOn, expectedArtifacts: s.expectedArtifacts, metadata: s.metadata,
|
||||
})),
|
||||
strategy: rawTask.strategy,
|
||||
});
|
||||
task.maxRetries = rawTask.maxRetries ?? 3;
|
||||
|
||||
// 构建 Runtime Context
|
||||
const rctx = createRuntimeContext({
|
||||
checklistAdapter: rawCtx.checklist ? (ctx) => ({ status: rawCtx.checklist === "pass" ? "pass" : "fail", blockers: [], warnings: [], confidence: 100 }) : null,
|
||||
baselineAdapter: rawCtx.baseline ? (_ctx, b, c) => ({ status: "pass", changes: [], blockers: [], warnings: [] }) : null,
|
||||
gateAdapter: rawCtx.gate ? autoGateAdapter : null,
|
||||
certificationAdapter: createCertificationAdapter(),
|
||||
baselineData: rawCtx.baselineData || null,
|
||||
candidateData: rawCtx.candidateData || null,
|
||||
pipelineOptions: options,
|
||||
});
|
||||
|
||||
try {
|
||||
let taskResult;
|
||||
if (action === "recover") {
|
||||
task.state = "failed";
|
||||
task.retryCount = rawTask.retryCount || 0;
|
||||
taskResult = recoverTask(task, rctx, options);
|
||||
} else {
|
||||
taskResult = executePipeline(task, rctx, options);
|
||||
}
|
||||
|
||||
const report = generateRuntimeReport(task, taskResult);
|
||||
const output = { success: taskResult.status === "passed", taskResult, report };
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
process.exit(taskResult.status === "passed" ? 0 : 1);
|
||||
} catch (e) {
|
||||
console.error(`Runtime error: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Executable
+269
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env bash
|
||||
# session-compact.sh — 会话压缩与快速存档 + Write Gate 前置检查
|
||||
#
|
||||
# v3 — 加入 pre-compact 钩子:上下文压缩前自动扫描未保存的纠正/决策/承诺
|
||||
# v2 — 加入 Write Gate 自动路由:preference/correction/decision 触发寄存器写入
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/session-compact.sh save "任何想记住的内容"
|
||||
# bash scripts/session-compact.sh save --type preference "偏好内容"
|
||||
# bash scripts/session-compact.sh save --type correction "纠正内容"
|
||||
# bash scripts/session-compact.sh save --type decision "决策内容"
|
||||
# bash scripts/session-compact.sh pre-compact # 上下文压缩前扫描 + 同步
|
||||
# bash scripts/session-compact.sh status # 看会话统计
|
||||
# bash scripts/session-compact.sh flush # 触发 Dream Cycle 同步
|
||||
# bash scripts/session-compact.sh check # Write Gate 诊断
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
MEMORY_SERVER="http://111.229.145.18"
|
||||
|
||||
# 加载环境变量(MEMORY_API_KEY 等)
|
||||
if [ -z "${MEMORY_API_KEY:-}" ] && [ -f ~/.zshrc ]; then
|
||||
MEMORY_API_KEY=$(grep '^export MEMORY_API_KEY=' ~/.zshrc 2>/dev/null | cut -d'"' -f2 || true)
|
||||
fi
|
||||
export MEMORY_API_KEY
|
||||
DAILY="$WORKSPACE/memory/daily/$(date +%Y-%m-%d).md"
|
||||
REGISTERS="$WORKSPACE/memory/registers"
|
||||
SESSION_LOG="$WORKSPACE/memory/.session-compact.log"
|
||||
|
||||
# ── Write Gate 路由 ──────────────────────────────────────
|
||||
|
||||
write_to_register() {
|
||||
local type="$1" content="$2" timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M')
|
||||
|
||||
case "$type" in
|
||||
preference)
|
||||
printf "\n| %s | %s | %s | session-compact | medium |\n" \
|
||||
"$(wc -l < "$REGISTERS/preferences.md" | tr -d ' ')" \
|
||||
"$content" "$timestamp" >> "$REGISTERS/preferences.md"
|
||||
echo " 📝 → preferences 寄存器"
|
||||
;;
|
||||
correction)
|
||||
printf "\n[correction: %s]\n- **new**: %s\n- **source**: session-compact\n" \
|
||||
"$timestamp" "$content" >> "$REGISTERS/open-loops.md"
|
||||
echo " 🔧 → open-loops 寄存器 (correction)"
|
||||
;;
|
||||
decision)
|
||||
printf "\n- %s: %s (session-compact)\n" "$timestamp" "$content" \
|
||||
>> "$WORKSPACE/memory/vault.md"
|
||||
echo " 📋 → vault.md (decision)"
|
||||
;;
|
||||
event)
|
||||
# 事件记录到 daily/ 已足够,不写寄存器
|
||||
echo " 📌 → daily/ (event,不写寄存器)"
|
||||
;;
|
||||
fact|behavior|code|rule|tool|project)
|
||||
# 新类型:写入 vault.md 的决策时间线
|
||||
printf "\n- %s: [%s] %s (session-compact)\n" "$timestamp" "$type" "$content" \
|
||||
>> "$WORKSPACE/memory/vault.md"
|
||||
echo " 📋 → vault.md ($type)"
|
||||
;;
|
||||
*)
|
||||
echo " ⚠️ 未知类型 '$type',仅写入 daily/"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── 命令处理 ─────────────────────────────────────────────
|
||||
|
||||
case "${1:-help}" in
|
||||
save)
|
||||
shift
|
||||
# 解析参数
|
||||
type=""
|
||||
content=""
|
||||
recall_context=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--type) type="$2"; shift 2 ;;
|
||||
--type=*) type="${1#*=}"; shift ;;
|
||||
--recall-context) recall_context="$2"; shift 2 ;;
|
||||
--recall-context=*) recall_context="${1#*=}"; shift ;;
|
||||
*) content="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$content" ]; then
|
||||
echo "用法: bash scripts/session-compact.sh save [--type <type>] [--recall-context <context>] <内容>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Step 1: LLM Write Gate 判断(如果未指定 type)──
|
||||
if [ -z "$type" ] && [ -f "$WORKSPACE/scripts/memory-write-gate.sh" ]; then
|
||||
echo " 🧠 Write Gate LLM 判断中..."
|
||||
GATE_RESULT=$(echo "$content" | bash "$WORKSPACE/scripts/memory-write-gate.sh" 2>/dev/null || echo '{"should_remember":false}')
|
||||
SHOULD_REMEMBER=$(echo "$GATE_RESULT" | jq -r '.should_remember // false' 2>/dev/null || echo "false")
|
||||
|
||||
if [ "$SHOULD_REMEMBER" = "true" ]; then
|
||||
type=$(echo "$GATE_RESULT" | jq -r '.type // ""' 2>/dev/null || echo "")
|
||||
importance=$(echo "$GATE_RESULT" | jq -r '.importance // 0.5' 2>/dev/null || echo "0.5")
|
||||
gate_content=$(echo "$GATE_RESULT" | jq -r '.content // ""' 2>/dev/null || echo "")
|
||||
gate_recall=$(echo "$GATE_RESULT" | jq -r '.recall_context // [] | join(",")' 2>/dev/null || echo "")
|
||||
gate_tags=$(echo "$GATE_RESULT" | jq -r '.tags // [] | join(",")' 2>/dev/null || echo "")
|
||||
|
||||
# 用 LLM 精炼的内容覆盖原始内容
|
||||
[ -n "$gate_content" ] && [ "$gate_content" != "null" ] && content="$gate_content"
|
||||
# 用 LLM 的 recall_context(如果用户没指定)
|
||||
[ -z "$recall_context" ] && [ -n "$gate_recall" ] && [ "$gate_recall" != "null" ] && recall_context="$gate_recall"
|
||||
|
||||
echo " ✅ Write Gate: 值得记 (type=$type, importance=$importance)"
|
||||
else
|
||||
gate_reason=$(echo "$GATE_RESULT" | jq -r '.reason // "not_important"' 2>/dev/null || echo "not_important")
|
||||
echo " ℹ️ Write Gate: 不值得记 ($gate_reason),仅写入 daily/"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Step 2: 写入 daily/(带 YAML frontmatter)──
|
||||
mkdir -p "$(dirname "$DAILY")"
|
||||
memory_id="mem_$(date +%Y%m%d)_$(openssl rand -hex 3 2>/dev/null || date +%s)"
|
||||
|
||||
# 如果有 recall_context,用 YAML frontmatter 格式
|
||||
if [ -n "$type" ] || [ -n "$recall_context" ]; then
|
||||
{
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "memory_id: $memory_id"
|
||||
[ -n "$type" ] && echo "type: $type"
|
||||
echo "created_at: $(date -Iseconds)"
|
||||
[ -n "$recall_context" ] && echo "recall_context: [$recall_context]"
|
||||
echo "---"
|
||||
echo "[session-flush] $(date +%H:%M) — $content"
|
||||
} >> "$DAILY"
|
||||
else
|
||||
echo "" >> "$DAILY"
|
||||
echo "[session-flush] $(date +%H:%M) — $content" >> "$DAILY"
|
||||
fi
|
||||
echo " ✅ 已写入 $DAILY (id: $memory_id)"
|
||||
|
||||
# ── Step 3: Write Gate — 写寄存器 ──
|
||||
if [ -n "$type" ]; then
|
||||
write_to_register "$type" "$content"
|
||||
fi
|
||||
|
||||
# ── Step 4: ContextGraph 集成 ──
|
||||
if [ "$type" = "decision" ] || [ "$type" = "correction" ] || [ "$type" = "event" ]; then
|
||||
node -e "
|
||||
try {
|
||||
const { ContextGraph } = require('$WORKSPACE/src/memory/context-graph');
|
||||
const g = new ContextGraph();
|
||||
const entityId = 'session:$(date +%Y%m%d)';
|
||||
g.entity(entityId, { type: 'session', name: 'Session $(date +%Y-%m-%d)' });
|
||||
g.observe(entityId, {
|
||||
type: '$type',
|
||||
priority: '$type' === 'decision' ? 'high' : 'normal',
|
||||
summary: process.argv[1].substring(0, 200),
|
||||
details: { source: 'session-compact', memory_id: '$memory_id' }
|
||||
});
|
||||
g.save();
|
||||
console.log(' 🕸️ → ContextGraph');
|
||||
} catch(e) { console.log(' ⚠️ ContextGraph: ' + e.message); }
|
||||
" "$content" 2>/dev/null || echo " ⚠️ ContextGraph 写入失败"
|
||||
fi
|
||||
|
||||
# ── Step 5: 推到远程服务器(带新字段)──
|
||||
clean=$(echo "$content" | sed 's/"/\\"/g' | head -c 400)
|
||||
remote_payload=$(jq -n \
|
||||
--arg c "$clean" \
|
||||
--arg t "${type:-session-flush}" \
|
||||
--arg rc "${recall_context:-}" \
|
||||
--arg mid "$memory_id" \
|
||||
'{content:$c, type:$t, project:"xiaolong", source:"session-compact", memory_id:$mid, recall_context:($rc | split(",") | map(select(. != "")))}')
|
||||
|
||||
resp=$(curl -s --max-time 5 -X POST "$MEMORY_SERVER/api/v2/add" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: ${MEMORY_API_KEY:-}" \
|
||||
-d "$remote_payload" 2>/dev/null) || true
|
||||
|
||||
if echo "$resp" | grep -q 'stored\|ok'; then
|
||||
echo " ✅ 已同步到远程服务器"
|
||||
else
|
||||
echo " ⚠️ 远程同步: ${resp:-无响应}"
|
||||
fi
|
||||
echo "$(date '+%Y-%m-%d %H:%M') | ${type:-general} | $content" >> "$SESSION_LOG"
|
||||
;;
|
||||
|
||||
check)
|
||||
echo "🔍 Write Gate 诊断"
|
||||
echo ""
|
||||
echo " 寄存器状态:"
|
||||
for f in "$REGISTERS"/*.md; do
|
||||
[ -f "$f" ] || continue
|
||||
name=$(basename "$f" .md)
|
||||
lines=$(wc -l < "$f" | tr -d ' ')
|
||||
size=$(wc -c < "$f" | tr -d ' ')
|
||||
echo " $name: ${lines}行 / ${size}字节"
|
||||
done
|
||||
echo ""
|
||||
echo " 最近 5 条 compact:"
|
||||
[ -f "$SESSION_LOG" ] && tail -5 "$SESSION_LOG" || echo " 无记录"
|
||||
echo ""
|
||||
echo " 今天 daily:"
|
||||
[ -f "$DAILY" ] && wc -l < "$DAILY" | xargs echo " lines:" || echo " (空)"
|
||||
;;
|
||||
|
||||
status)
|
||||
echo "📊 会话状态"
|
||||
echo " 每日日志: $DAILY"
|
||||
echo " 寄存器:"
|
||||
ls "$REGISTERS"/*.md 2>/dev/null | while read -r f; do
|
||||
echo " $(basename "$f" .md): $(wc -l < "$f") 行"
|
||||
done
|
||||
echo " Compact 历史:"
|
||||
[ -f "$SESSION_LOG" ] && tail -5 "$SESSION_LOG" || echo " 无记录"
|
||||
;;
|
||||
|
||||
flush)
|
||||
echo "🔄 触发立即存档..."
|
||||
bash "$WORKSPACE/scripts/dream-cycle.sh" sync-remote 2>&1
|
||||
;;
|
||||
|
||||
pre-compact)
|
||||
echo "🛡️ Pre-Compaction Hook — 上下文压缩前安全检查"
|
||||
echo ""
|
||||
|
||||
has_flush=false
|
||||
if [ -f "$DAILY" ]; then
|
||||
if grep -q '\[session-flush\]' "$DAILY"; then
|
||||
echo " ✅ 今天已有 [session-flush] 标记"
|
||||
has_flush=true
|
||||
grep '\[session-flush\]' "$DAILY" | tail -3 | while read -r line; do
|
||||
echo " $(echo "$line" | cut -c1-80)"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$has_flush" = false ]; then
|
||||
echo " ⚠️ 今天 daily/ 无 [session-flush] 标记 — 压缩前请先存档关键信息"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " 🌐 远程服务器:"
|
||||
remote_resp=$(curl -s --max-time 5 "$MEMORY_SERVER/api/v2/stats" 2>/dev/null || echo '{"error":"unreachable"}')
|
||||
if echo "$remote_resp" | grep -q 'xiaolong'; then
|
||||
echo " ✅ 在线"
|
||||
else
|
||||
echo " ⚠️ 不可达"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " 📤 触发同步..."
|
||||
bash "$WORKSPACE/scripts/dream-cycle.sh" sync-remote 2>&1 | sed 's/^/ /'
|
||||
echo ""
|
||||
echo " ✅ Pre-Compaction Hook 完成"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "用法:"
|
||||
echo " bash scripts/session-compact.sh save <内容> — 快速存关键信息"
|
||||
echo " bash scripts/session-compact.sh save --type preference <内容> — 记偏好(→registers)"
|
||||
echo " bash scripts/session-compact.sh save --type correction <内容> — 记纠正(→registers)"
|
||||
echo " bash scripts/session-compact.sh save --type decision <内容> — 记决策(→vault)"
|
||||
echo " bash scripts/session-compact.sh pre-compact — 压缩前安全检查 + 同步"
|
||||
echo " bash scripts/session-compact.sh status — 查看状态"
|
||||
echo " bash scripts/session-compact.sh check — Write Gate 诊断"
|
||||
echo " bash scripts/session-compact.sh flush — 触发全部同步"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* PR-D Smoke Test
|
||||
* Quick sanity check: does the toolchain work?
|
||||
*
|
||||
* Run: node scripts/smoke-test.mjs
|
||||
*
|
||||
* Checks (7 items):
|
||||
* 1. openclaw CLI executable
|
||||
* 2. Baseline test files exist
|
||||
* 3. Guard scripts exist
|
||||
* 4. Deprecation check script exists
|
||||
* 5. package.json scripts available
|
||||
* 6. Minimal CLI command runs
|
||||
* 7. Version or help info outputs
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = join(fileURLToPath(import.meta.url), "..");
|
||||
const ROOT = join(__dirname, "..");
|
||||
|
||||
let PASS = 0;
|
||||
let FAIL = 0;
|
||||
|
||||
function check(label, ok, detail = "") {
|
||||
const mark = ok ? "✓" : "✗";
|
||||
if (ok) PASS++; else FAIL++;
|
||||
console.log(` [${mark}] ${label}${detail ? ` — ${detail}` : ""}`);
|
||||
return ok;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("──────────────────────────────────────────");
|
||||
console.log(" PR-D: Smoke Test");
|
||||
console.log("──────────────────────────────────────────");
|
||||
console.log("");
|
||||
|
||||
// ─── 1. openclaw CLI executable ──────────────────
|
||||
let ocVersion = "";
|
||||
let ocOk = false;
|
||||
try {
|
||||
ocVersion = execSync("openclaw --version 2>&1", {
|
||||
encoding: "utf8",
|
||||
timeout: 10000,
|
||||
}).trim();
|
||||
ocOk = ocVersion.length > 0;
|
||||
} catch {
|
||||
ocOk = false;
|
||||
}
|
||||
check("openclaw CLI executable", ocOk, ocVersion || "not found");
|
||||
|
||||
// ─── 2. Baseline test files exist ──────────────────
|
||||
const baselineDir = join(ROOT, "test", "baseline");
|
||||
let baselineCount = 0;
|
||||
let baselineOk = false;
|
||||
try {
|
||||
if (existsSync(baselineDir)) {
|
||||
const files = readdirSync(baselineDir).filter(f => f.startsWith("test-") && f.endsWith(".test.mjs"));
|
||||
baselineCount = files.length;
|
||||
baselineOk = baselineCount > 0;
|
||||
}
|
||||
} catch {}
|
||||
check("Baseline test files exist", baselineOk, `${baselineCount} files`);
|
||||
|
||||
// ─── 3. Guard scripts exist ────────────────────────
|
||||
const guardScript = join(ROOT, "scripts", "guard-all.sh");
|
||||
let guardOk = existsSync(guardScript);
|
||||
check("Guard scripts exist", guardOk, guardScript);
|
||||
|
||||
// ─── 4. Deprecation check script exists ─────────────
|
||||
const deprecationScript = join(ROOT, "scripts", "check-deprecations.mjs");
|
||||
let depOk = existsSync(deprecationScript);
|
||||
check("Deprecation check script exists", depOk, deprecationScript);
|
||||
|
||||
// ─── 5. package.json scripts available ─────────────
|
||||
let pkgOk = false;
|
||||
let pkgDetail = "";
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
|
||||
const scripts = Object.keys(pkg.scripts || {});
|
||||
const required = ["production-check", "smoke", "guard", "check:deprecations", "test:baseline"];
|
||||
const missing = required.filter(s => !scripts.includes(s));
|
||||
|
||||
if (missing.length === 0) {
|
||||
pkgOk = true;
|
||||
pkgDetail = `${scripts.length} scripts, all required present`;
|
||||
} else {
|
||||
pkgDetail = `missing: ${missing.join(", ")}`;
|
||||
}
|
||||
} catch (e) {
|
||||
pkgDetail = `error: ${e.message}`;
|
||||
}
|
||||
check("package.json scripts available", pkgOk, pkgDetail);
|
||||
|
||||
// ─── 6. Minimal CLI command runs ───────────────────
|
||||
let cliRunOk = false;
|
||||
let cliDetail = "";
|
||||
try {
|
||||
const out = execSync("openclaw --help 2>&1", {
|
||||
encoding: "utf8",
|
||||
timeout: 15000,
|
||||
});
|
||||
cliRunOk = out.length > 0;
|
||||
cliDetail = `${out.length} bytes output`;
|
||||
} catch (e) {
|
||||
cliDetail = `error: ${e.message}`;
|
||||
}
|
||||
check("Minimal CLI command runs", cliRunOk, cliDetail);
|
||||
|
||||
// ─── 7. Version or help info outputs ────────────────
|
||||
let versionOk = false;
|
||||
let versionDetail = "";
|
||||
try {
|
||||
const v = execSync("openclaw --version 2>&1", {
|
||||
encoding: "utf8",
|
||||
timeout: 10000,
|
||||
}).trim();
|
||||
versionOk = v.length > 0;
|
||||
versionDetail = v;
|
||||
} catch (e) {
|
||||
versionDetail = `error: ${e.message}`;
|
||||
}
|
||||
check("Version/help info outputs", versionOk, versionDetail);
|
||||
|
||||
// ─── Summary ────────────────────────────────────────
|
||||
console.log("");
|
||||
console.log("──────────────────────────────────────────");
|
||||
const total = PASS + FAIL;
|
||||
console.log(` Total: ${total} | Pass: ${PASS} | Fail: ${FAIL}`);
|
||||
if (FAIL === 0) {
|
||||
console.log(" ✓ Smoke test PASSED");
|
||||
console.log("──────────────────────────────────────────");
|
||||
console.log("");
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(" ✗ Smoke test FAILED");
|
||||
console.log("──────────────────────────────────────────");
|
||||
console.log("");
|
||||
process.exit(1);
|
||||
}
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# sync-agent-repos.sh — 同步 GitHub Agent 框架到 Gitea(排除大资源文件)
|
||||
set -eo pipefail
|
||||
|
||||
GITEA="https://git666.u7f.cn"
|
||||
GITEA_USER="sawz"
|
||||
GITEA_PASS="45324622ww"
|
||||
GITEA_ORG="Github"
|
||||
PROXY="socks5://127.0.0.1:12002"
|
||||
WORK_DIR="/tmp/gitea-sync"
|
||||
mkdir -p "$WORK_DIR"
|
||||
|
||||
REPO_LIST=(
|
||||
"MetaGPT|FoundationAgents/MetaGPT"
|
||||
"CrewAI|crewAIInc/crewAI"
|
||||
"AutoGen|microsoft/autogen"
|
||||
"Mastra|mastra-ai/mastra"
|
||||
"MCP-Use|mcp-use/mcp-use"
|
||||
"RagaAI-Catalyst|raga-ai-hub/RagaAI-Catalyst"
|
||||
)
|
||||
|
||||
EXCLUDE_PATTERNS=(
|
||||
"*.gif" "*.mp4" "*.mov" "*.avi" "*.webm" "*.webp"
|
||||
"*.png" "*.jpg" "*.jpeg" "*.svg" "*.ico"
|
||||
"*.woff" "*.woff2" "*.ttf" "*.eot"
|
||||
"*.zip" "*.tar" "*.gz" "*.bz2" "*.7z" "*.rar"
|
||||
"*.pdf" "*.whl" "*.egg" "*.jar"
|
||||
"docs/images/*" "docs/*/images/*" "static/images/*"
|
||||
)
|
||||
|
||||
sync_repo() {
|
||||
local name="$1" github_path="$2" clone_dir="$WORK_DIR/$name"
|
||||
echo "=== [$name] ==="
|
||||
|
||||
# 检查是否已存在
|
||||
if curl -sf -u "$GITEA_USER:$GITEA_PASS" "$GITEA/api/v1/repos/$GITEA_ORG/$name" >/dev/null 2>&1; then
|
||||
# 增量更新
|
||||
local remote_sha=$(curl -sf --proxy "$PROXY" "https://api.github.com/repos/$github_path" 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin).get('pushed_at',''))" 2>/dev/null)
|
||||
local local_sha=$(curl -sf -u "$GITEA_USER:$GITEA_PASS" "$GITEA/api/v1/repos/$GITEA_ORG/$name/commits?limit=1" 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['commit']['committer']['date'] if d else '')" 2>/dev/null)
|
||||
if [ "$remote_sha" = "$local_sha" ] 2>/dev/null; then
|
||||
echo " ℹ️ 无更新"; return 0
|
||||
fi
|
||||
echo " 🔄 检测到更新,重新同步..."
|
||||
curl -sf -u "$GITEA_USER:$GITEA_PASS" -X DELETE "$GITEA/api/v1/repos/$GITEA_ORG/$name" >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
rm -rf "$clone_dir"
|
||||
echo " 📥 克隆中..."
|
||||
GIT_HTTP_PROXY="$PROXY" git clone --depth=1 "https://github.com/$github_path.git" "$clone_dir" 2>/dev/null
|
||||
cd "$clone_dir"
|
||||
|
||||
echo " 🧹 排除大文件..."
|
||||
for p in "${EXCLUDE_PATTERNS[@]}"; do
|
||||
find . -not -path './.git/*' -name "$p" -type f -delete 2>/dev/null
|
||||
done
|
||||
find . -not -path './.git/*' -type d -empty -delete 2>/dev/null
|
||||
|
||||
curl -sf -u "$GITEA_USER:$GITEA_PASS" -X POST "$GITEA/api/v1/orgs/$GITEA_ORG/repos" \
|
||||
-H "Content-Type: application/json" -d "{\"name\":\"$name\",\"private\":false,\"auto_init\":false}" 2>/dev/null || true
|
||||
|
||||
rm -rf .git && git init -q && git add -A
|
||||
git commit -q -m "sync: code-only from github.com/$github_path" --allow-empty
|
||||
git remote add origin "$GITEA/$GITEA_ORG/$name.git"
|
||||
git push -u origin HEAD:main --force 2>/dev/null || git push -u origin HEAD:master --force 2>/dev/null
|
||||
|
||||
local size=$(du -sh . 2>/dev/null | cut -f1)
|
||||
echo " ✅ $name — $size"
|
||||
cd "$WORK_DIR"
|
||||
rm -rf "$clone_dir"
|
||||
}
|
||||
|
||||
echo "# Agent 框架同步 — $(date '+%Y-%m-%d %H:%M')"
|
||||
for entry in "${REPO_LIST[@]}"; do
|
||||
sync_repo "${entry%%|*}" "${entry#*|}" 2>&1
|
||||
done
|
||||
echo "🎉 完成 — $(date '+%H:%M')"
|
||||
@@ -0,0 +1,26 @@
|
||||
# Blocker
|
||||
|
||||
<!-- Governance Reference: GOVERNANCE.md §3 -->
|
||||
<!-- One blocker per file. If multiple blockers, create multiple files. -->
|
||||
|
||||
- **blocker**: <一句话描述阻塞(禁止模糊描述)>
|
||||
- **root_cause**: <根本原因,不是表象。反例:"XXXX 没响应"。正例:"XXXX 依赖 YYYY 配置中的 ZZZZ 字段,但该字段在 v2.3 中已废弃">
|
||||
- **owner**: <负责人 / agent id>
|
||||
- **mitigation**: <缓解方案或至少一个尝试方向>
|
||||
- **dependency**: <依赖的外部资源、API、其他 Agent Package、或用户输入>
|
||||
- **created_at**: <ISO-8601 时间戳>
|
||||
|
||||
<!-- 以下字段在解除时填充 -->
|
||||
|
||||
- **resolved_at**: <可选>
|
||||
- **escalated**: <YES / NO — 是否需要红尘介入>
|
||||
|
||||
---
|
||||
|
||||
## Escalation Notes
|
||||
|
||||
<如果 escalated: YES,在此说明为什么需要介入,以及已经尝试过的方案>
|
||||
|
||||
## Resolution Notes
|
||||
|
||||
<可选 — 解除后记录如何解决的>
|
||||
@@ -0,0 +1,53 @@
|
||||
# Executive Dashboard — YYYY-MM-DD
|
||||
|
||||
<!-- Canonical Reference: PORTFOLIO.md §12 -->
|
||||
|
||||
## Portfolio Health: XX → <Rating>
|
||||
|
||||
## Top Projects
|
||||
|
||||
| Rank | Project | Health | Priority | Progress | Trend |
|
||||
|------|---------|--------|----------|----------|-------|
|
||||
| 1 | Alpha | 92 | P0 | 85% | ↑ |
|
||||
| 2 | Beta | 45 | P1 | 30% | ↓ |
|
||||
|
||||
## Top Risks
|
||||
|
||||
| # | Risk | Project | Mitigation | Severity |
|
||||
|---|------|---------|-----------|----------|
|
||||
| 1 | API dependency timeout | Beta | Switch to cached fallback | HIGH |
|
||||
| 2 | Resource contention | Delta | Pause P3 projects | MEDIUM |
|
||||
|
||||
## Top Opportunities
|
||||
|
||||
| # | Opportunity | Expected ROI | Effort |
|
||||
|---|-------------|-------------|--------|
|
||||
| 1 | Extract shared Auth module | 40h saved across 3 projects | 8h |
|
||||
| 2 | Reuse GraphQL schema from Gamma | 15h saved | 3h |
|
||||
|
||||
## Agent Utilization
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Rating | <Optimal / Good / Acceptable / Waste> |
|
||||
| Busy | N |
|
||||
| Idle | N |
|
||||
| Blocked | N |
|
||||
| Recovery | N |
|
||||
|
||||
## Learning Reuse
|
||||
|
||||
| Pattern | Reuse Count | Saved Effort |
|
||||
|---------|-------------|-------------|
|
||||
| Auth Module | 3 projects | ~40h |
|
||||
| SQLite FK pattern | 2 projects | ~6h |
|
||||
|
||||
## Quick Actions
|
||||
|
||||
- [ ] Pause Delta (P3, blocked > 48h)
|
||||
- [ ] Extract Auth shared package from Alpha and Gamma
|
||||
- [ ] Review Beta recovery plan
|
||||
|
||||
---
|
||||
|
||||
Generated: YYYY-MM-DDTHH:mm:ss+08:00
|
||||
@@ -0,0 +1,57 @@
|
||||
# Execution Audit — YYYY-MM-DD
|
||||
|
||||
<!-- Governance Reference: GOVERNANCE.md §6 -->
|
||||
<!-- 每天填充。项目结束后保留作为归档。 -->
|
||||
|
||||
## Summary
|
||||
|
||||
- **completion_rate**: <已完成 / 总任务 × 100%>
|
||||
- **blocked_rate**: <BLOCKED / 总任务 × 100%>
|
||||
- **stale_rate**: <STALE / 总任务 × 100%>
|
||||
- **failure_rate**: <FAILED / 总任务 × 100%>
|
||||
- **replan_count**: <当天重规划次数>
|
||||
- **avg_cycle_time**: <从 NOT_STARTED 到 VERIFIED 的平均经过时间>
|
||||
|
||||
## Task Status Matrix
|
||||
|
||||
| Agent-ID | Task ID | Status | Last Heartbeat | Cycle Time |
|
||||
|----------|---------|--------|---------------|------------|
|
||||
| AP-01 | T1 | IN_PROGRESS | YYYY-MM-DD HH:mm | — |
|
||||
| AP-01 | T2 | NOT_STARTED | — | — |
|
||||
|
||||
## Blockers (Active)
|
||||
|
||||
| Blocker | Owner | Age | Escalated |
|
||||
|---------|-------|-----|-----------|
|
||||
| <一句话> | <agent id> | <小时> | YES/NO |
|
||||
|
||||
## Replans
|
||||
|
||||
<!-- 引用 GOVERNANCE.md §4.3 格式 -->
|
||||
|
||||
### Replan #1 (YYYY-MM-DD HH:mm)
|
||||
|
||||
- **trigger**: <R1–R8>
|
||||
- **cause**: <失败原因>
|
||||
- **before**: <原始 Task Tree 摘要>
|
||||
- **after**: <修订后 Task Tree 摘要>
|
||||
- **impact**: <影响到的 Agent Package 列表>
|
||||
|
||||
## Health Score
|
||||
|
||||
<!-- 计算参考 GOVERNANCE.md §7 -->
|
||||
|
||||
- **Delivery Score**: <0–100>
|
||||
- **Quality Score**: <0–100>
|
||||
- **Parallelism Score**: <0–100>
|
||||
- **Dependency Score**: <0–100>
|
||||
- **Learning Score**: <0–100>
|
||||
- **Final**: <加权总分> → <评级>
|
||||
|
||||
## Override Log
|
||||
|
||||
<!-- Governance Reference: GOVERNANCE.md §10.3 -->
|
||||
|
||||
| Date | Rule Overridden | By | Reason |
|
||||
|------|----------------|----|--------|
|
||||
| YYYY-MM-DD | <rule name> | 红尘 | <reason> |
|
||||
@@ -0,0 +1,54 @@
|
||||
# Portfolio Audit — YYYY-MM-DD
|
||||
|
||||
<!-- Canonical Reference: PORTFOLIO.md §8 -->
|
||||
|
||||
## Active Projects
|
||||
|
||||
| Project-ID | Name | Health | Priority | Progress | Last Heartbeat | Risk Flag |
|
||||
|------------|------|--------|----------|----------|----------------|-----------|
|
||||
| P-001 | Alpha | 85 | P1 | 65% | YYYY-MM-DD HH:mm | 🟢 |
|
||||
| P-002 | Beta | 45 | P1 | 30% | YYYY-MM-DD HH:mm | 🔴 Health < 50 |
|
||||
|
||||
## Completed Projects
|
||||
|
||||
| Project-ID | Name | Final Health | Total Duration | ROI |
|
||||
|------------|------|-------------|---------------|-----|
|
||||
| P-003 | Gamma | 92 | 14d | 3.2 |
|
||||
|
||||
## Blocked Projects
|
||||
|
||||
| Project-ID | Name | Since | Blocker | Escalated |
|
||||
|------------|------|-------|---------|-----------|
|
||||
| P-004 | Delta | YYYY-MM-DD | Dependency on API v3 | YES |
|
||||
|
||||
## Recovery Projects
|
||||
|
||||
| Project-ID | Name | Entries | Attempts | Status |
|
||||
|------------|------|---------|----------|--------|
|
||||
| P-005 | Epsilon | YYYY-MM-DD | 2 | RECOVERY |
|
||||
|
||||
## Resource Report
|
||||
|
||||
- **Total Agents**: N
|
||||
- **Busy Agents**: N
|
||||
- **Idle Agents**: N (Utilization: XX%)
|
||||
- **Blocked Agents**: N
|
||||
- **Recovery Agents**: N
|
||||
|
||||
## Duplicate Work Report
|
||||
|
||||
| Pattern Class | Projects | Action Status |
|
||||
|---------------|----------|--------------|
|
||||
| Auth Module | P-001, P-003 | Extracting to shared package |
|
||||
|
||||
## Top Risks
|
||||
|
||||
1. <risk description> — <project>
|
||||
2. <risk description> — <project>
|
||||
|
||||
## Portfolio Health: XX → <Rating>
|
||||
|
||||
---
|
||||
|
||||
Generated: YYYY-MM-DDTHH:mm:ss+08:00
|
||||
Next Audit: YYYY-MM-DD
|
||||
@@ -0,0 +1,26 @@
|
||||
# Portfolio Registry
|
||||
|
||||
<!-- Last Updated: YYYY-MM-DDTHH:mm:ss+08:00 -->
|
||||
<!-- Canonical Reference: PORTFOLIO.md §1 -->
|
||||
|
||||
## Summary
|
||||
|
||||
- **Total Projects**: N
|
||||
- **Active**: N / **Blocked**: N / **Recovery**: N / **Paused**: N
|
||||
- **Completed**: N / **Archived**: N
|
||||
- **Agent Utilization**: XX% (<Rating>)
|
||||
- **Portfolio Health**: XX → <Rating>
|
||||
|
||||
## Projects
|
||||
|
||||
| Project-ID | Name | Status | Priority | Health | Agents | Progress | Last Updated | ROI |
|
||||
|------------|------|--------|----------|--------|--------|----------|-------------|-----|
|
||||
| P-001 | Project Alpha | ACTIVE | P1 | 85 | 3 | 65% | YYYY-MM-DD | 2.4 |
|
||||
| P-002 | Project Beta | PLANNING | P2 | — | 1 | 0% | YYYY-MM-DD | 1.8 |
|
||||
|
||||
## Recent Changes
|
||||
|
||||
| Date | Project | Change |
|
||||
|------|---------|--------|
|
||||
| YYYY-MM-DD | P-001 | Status: ACTIVE → BLOCKED (dependency timeout) |
|
||||
| YYYY-MM-DD | P-002 | Status: PLANNING → ACTIVE (resources allocated) |
|
||||
@@ -0,0 +1,21 @@
|
||||
# progress.log — Heartbeat Log
|
||||
# Format: JSON Lines, one entry per heartbeat
|
||||
# Governance Reference: GOVERNANCE.md §2
|
||||
|
||||
# === INITIAL HEARTBEAT (on start) ===
|
||||
{"ts":"YYYY-MM-DDTHH:mm:ss+08:00","status":"NOT_STARTED","completed":[],"remaining":["T1","T2","T3","T4"],"risk":"","next_action":"awaiting kickoff"}
|
||||
|
||||
# === FIRST HEARTBEAT (on IN_PROGRESS) ===
|
||||
{"ts":"YYYY-MM-DDTHH:mm:ss+08:00","status":"IN_PROGRESS","completed":[],"remaining":["T1","T2","T3","T4"],"risk":"","next_action":"start T1"}
|
||||
|
||||
# === PROGRESS HEARTBEAT ===
|
||||
{"ts":"YYYY-MM-DDTHH:mm:ss+08:00","status":"IN_PROGRESS","completed":["T1","T2"],"remaining":["T3","T4"],"risk":"T3 depends on external API availability","next_action":"execute T3"}
|
||||
|
||||
# === BLOCKED HEARTBEAT ===
|
||||
{"ts":"YYYY-MM-DDTHH:mm:ss+08:00","status":"BLOCKED","completed":["T1"],"remaining":["T2","T3","T4"],"risk":"T2 blocked by missing config","next_action":"create blocker.md"}
|
||||
|
||||
# === READY_FOR_REVIEW HEARTBEAT ===
|
||||
{"ts":"YYYY-MM-DDTHH:mm:ss+08:00","status":"READY_FOR_REVIEW","completed":["T1","T2","T3"],"remaining":["T4"],"risk":"","next_action":"awaiting review for T1-T3"}
|
||||
|
||||
# === FINAL HEARTBEAT (on VERIFIED → ARCHIVED) ===
|
||||
{"ts":"YYYY-MM-DDTHH:mm:ss+08:00","status":"ARCHIVED","completed":["T1","T2","T3","T4"],"remaining":[],"risk":"","next_action":"none"}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Recovery Report
|
||||
|
||||
<!-- Governance Reference: GOVERNANCE.md §8 -->
|
||||
<!-- 进入 RECOVERY MODE 后必须生成。需要用户审批后才能退出。 -->
|
||||
|
||||
## Snapshot
|
||||
|
||||
- **trigger**: <A1 / A2 / A3>
|
||||
- **entered_at**: <ISO-8601 时间戳>
|
||||
|
||||
## State Snapshot
|
||||
|
||||
### FAILED
|
||||
|
||||
| Agent-ID | Task ID | Last Status | Failure Count | Root Cause |
|
||||
|----------|---------|-------------|---------------|------------|
|
||||
|
||||
### BLOCKED
|
||||
|
||||
| Agent-ID | Task ID | Blocked Since | Blocker | Escalated |
|
||||
|----------|---------|---------------|---------|-----------|
|
||||
|
||||
### STALE
|
||||
|
||||
| Agent-ID | Task ID | Last Heartbeat | Stale Since |
|
||||
|----------|---------|---------------|-------------|
|
||||
|
||||
## Root Cause Chain
|
||||
|
||||
<!-- 追溯:最早在哪个决策点出问题? -->
|
||||
|
||||
1. <首次偏差>
|
||||
2. <累积效应>
|
||||
3. <触发点>
|
||||
|
||||
## Recovery Plan
|
||||
|
||||
<!-- 调整后的 Task Tree 和 PLAN -->
|
||||
|
||||
### Revised Task Tree
|
||||
|
||||
| Task ID | Name | Depends On | Status |
|
||||
|---------|------|------------|--------|
|
||||
|
||||
### Revised Plan
|
||||
|
||||
1. <step>
|
||||
2. <step>
|
||||
|
||||
## Risk Acceptance
|
||||
|
||||
<用户签署后填充>
|
||||
|
||||
- **accepted_by**: <签名>
|
||||
- **accepted_at**: <ISO-8601 时间戳>
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/bin/bash
|
||||
# ====================================================
|
||||
# PR-A Baseline Test Suite Runner
|
||||
# OpenClaw Agent OS v2 — Architecture Stabilization
|
||||
# ====================================================
|
||||
# Freezes current system behavior as safety net
|
||||
# for subsequent architecture refactoring.
|
||||
# ====================================================
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
TEST_DIR="$SCRIPT_DIR/../test/baseline"
|
||||
WORKSPACE="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}============================================${NC}"
|
||||
echo -e "${CYAN} OpenClaw Agent OS v2 — PR-A Baseline Test${NC}"
|
||||
echo -e "${CYAN} $(date '+%Y-%m-%d %H:%M:%S %Z')${NC}"
|
||||
echo -e "${CYAN}============================================${NC}"
|
||||
echo ""
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SKIP=0
|
||||
RESULTS=""
|
||||
|
||||
run_test() {
|
||||
local file="$1"
|
||||
local name
|
||||
name="$(basename "$file" .test.js)"
|
||||
|
||||
echo -e "${YELLOW}[RUN ]${NC} $name"
|
||||
|
||||
if node --test "$file" 2>&1; then
|
||||
PASS=$((PASS + 1))
|
||||
RESULTS="$RESULTS [PASS] $name\n"
|
||||
echo -e "${GREEN}[PASS]${NC} $name"
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
RESULTS="$RESULTS [FAIL] $name\n"
|
||||
echo -e "${RED}[FAIL]${NC} $name"
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Run all 12 tests
|
||||
for i in $(seq -w 1 12); do
|
||||
test_file="$TEST_DIR/test-${i}-*.test.js"
|
||||
# Expand glob
|
||||
for f in $test_file; do
|
||||
if [ -f "$f" ]; then
|
||||
run_test "$f"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}============================================${NC}"
|
||||
echo -e "${CYAN} Results Summary${NC}"
|
||||
echo -e "${CYAN}============================================${NC}"
|
||||
echo -e "$RESULTS"
|
||||
echo ""
|
||||
echo -e "Total: $((PASS + FAIL)) | ${GREEN}Pass: $PASS${NC} | ${RED}Fail: $FAIL${NC}"
|
||||
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ ALL BASELINE TESTS PASSED${NC}"
|
||||
echo -e "${GREEN} System behavior frozen — safe to proceed to PR-B${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo ""
|
||||
echo -e "${RED}✗ SOME TESTS FAILED${NC}"
|
||||
echo -e "${RED} Review failures before proceeding to PR-B${NC}"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* upgrade-test.mjs — OpenClaw Upgrade Compatibility Test Suite
|
||||
*
|
||||
* Checks:
|
||||
* 1. AGENTS compatibility — all sections present
|
||||
* 2. GOVERNANCE compatibility — all sections present, state machine valid
|
||||
* 3. PORTFOLIO compatibility — all sections present, project states valid
|
||||
* 4. Audit compatibility — all 4 audit scripts run without crash
|
||||
* 5. Template compatibility — all 7 templates are valid markdown
|
||||
* 6. Cross-ref integrity — no broken references between docs
|
||||
* 7. Version Contract compliance — required interfaces exist
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/upgrade-test.mjs
|
||||
*
|
||||
* Returns exit code 0 if all PASS, 1 if any FAIL.
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const WORKSPACE = path.resolve(__dirname, '..');
|
||||
|
||||
// ── Test Framework ──
|
||||
let testsPassed = 0;
|
||||
let testsFailed = 0;
|
||||
let testsTotal = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
testsTotal++;
|
||||
try {
|
||||
const result = fn();
|
||||
if (result === true || result === undefined) {
|
||||
console.log(` ✅ PASS: ${name}`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log(` ❌ FAIL: ${name}`);
|
||||
console.log(` ${result}`);
|
||||
testsFailed++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` ❌ FAIL: ${name}`);
|
||||
console.log(` ${e.message}`);
|
||||
testsFailed++;
|
||||
}
|
||||
}
|
||||
|
||||
function read(rel) {
|
||||
const p = path.join(WORKSPACE, rel);
|
||||
if (!fs.existsSync(p)) throw new Error(`File not found: ${rel}`);
|
||||
return fs.readFileSync(p, 'utf8');
|
||||
}
|
||||
|
||||
function hasSection(content, sectionPattern) {
|
||||
return sectionPattern.test(content);
|
||||
}
|
||||
|
||||
// ── Run Audit Scripts (capture output, don't fail on WARN) ──
|
||||
function runScript(rel) {
|
||||
const result = spawnSync('node', [path.join(WORKSPACE, rel)], {
|
||||
encoding: 'utf8',
|
||||
timeout: 30000,
|
||||
});
|
||||
if (result.error) throw new Error(`Script execution error: ${result.error.message}`);
|
||||
return { stdout: result.stdout, stderr: result.stderr, status: result.status };
|
||||
}
|
||||
|
||||
console.log(`\nOpenClaw Upgrade Compatibility Test Suite`);
|
||||
console.log(`Workspace: ${WORKSPACE}`);
|
||||
console.log(`${'='.repeat(60)}\n`);
|
||||
|
||||
// ── 1. AGENTS Compatibility ──
|
||||
console.log(`\n--- 1. AGENTS Compatibility ---\n`);
|
||||
|
||||
test('AGENTS.md exists', () => {
|
||||
const content = read('AGENTS.md');
|
||||
return content.length > 100 ? true : 'File too short';
|
||||
});
|
||||
|
||||
test('AGENTS.md §0 Complexity Classification', () => {
|
||||
const content = read('AGENTS.md');
|
||||
return hasSection(content, /###\s*0\.\s*Complexity Classification/) ? true : 'Missing §0';
|
||||
});
|
||||
|
||||
test('AGENTS.md §1 Task Tree Construction', () => {
|
||||
const content = read('AGENTS.md');
|
||||
return hasSection(content, /###\s*1\.\s*Task Tree/) ? true : 'Missing §1';
|
||||
});
|
||||
|
||||
test('AGENTS.md §2 Planning Protocol', () => {
|
||||
const content = read('AGENTS.md');
|
||||
return hasSection(content, /###\s*2\.\s*Planning Protocol/) ? true : 'Missing §2';
|
||||
});
|
||||
|
||||
test('AGENTS.md §3 Pre-flight Check', () => {
|
||||
const content = read('AGENTS.md');
|
||||
return hasSection(content, /###\s*3\.\s*Pre-flight/) ? true : 'Missing §3';
|
||||
});
|
||||
|
||||
test('AGENTS.md §4 Failure Replanning', () => {
|
||||
const content = read('AGENTS.md');
|
||||
return hasSection(content, /###\s*4\.\s*Failure Replanning/) ? true : 'Missing §4';
|
||||
});
|
||||
|
||||
test('AGENTS.md §5 Verification Protocol', () => {
|
||||
const content = read('AGENTS.md');
|
||||
return hasSection(content, /###\s*5\.\s*Verification Protocol/) ? true : 'Missing §5';
|
||||
});
|
||||
|
||||
test('AGENTS.md §6 Governance Compliance', () => {
|
||||
const content = read('AGENTS.md');
|
||||
return hasSection(content, /###\s*6\.\s*Governance Compliance/) ? true : 'Missing §6';
|
||||
});
|
||||
|
||||
test('AGENTS.md §7 Auto-Capture Trigger', () => {
|
||||
const content = read('AGENTS.md');
|
||||
return hasSection(content, /###\s*7\.\s*Auto-Capture/) ? true : 'Missing §7';
|
||||
});
|
||||
|
||||
test('AGENTS.md Learning Loop', () => {
|
||||
const content = read('AGENTS.md');
|
||||
return hasSection(content, /## Learning Loop/) ? true : 'Missing Learning Loop';
|
||||
});
|
||||
|
||||
test('AGENTS.md Context Budget', () => {
|
||||
const content = read('AGENTS.md');
|
||||
return hasSection(content, /## Agent Package Context Budget/) ? true : 'Missing Context Budget';
|
||||
});
|
||||
|
||||
// ── 2. GOVERNANCE Compatibility ──
|
||||
console.log(`\n--- 2. GOVERNANCE Compatibility ---\n`);
|
||||
|
||||
test('GOVERNANCE.md exists', () => {
|
||||
const content = read('GOVERNANCE.md');
|
||||
return content.length > 100 ? true : 'File too short';
|
||||
});
|
||||
|
||||
test('GOVERNANCE.md §1 State Machine (8 states)', () => {
|
||||
const content = read('GOVERNANCE.md');
|
||||
const states = ['NOT_STARTED', 'IN_PROGRESS', 'BLOCKED', 'WAITING_DEPENDENCY', 'READY_FOR_REVIEW', 'VERIFIED', 'FAILED', 'ARCHIVED'];
|
||||
const missing = states.filter(s => !content.includes(s));
|
||||
return missing.length === 0 ? true : `Missing states: ${missing.join(', ')}`;
|
||||
});
|
||||
|
||||
test('GOVERNANCE.md §1 Transition Rules', () => {
|
||||
const content = read('GOVERNANCE.md');
|
||||
return hasSection(content, /Transition Table/) || hasSection(content, /§1\.3/) ? true : 'No transition rules found';
|
||||
});
|
||||
|
||||
test('GOVERNANCE.md §2 Heartbeat Rule', () => {
|
||||
const content = read('GOVERNANCE.md');
|
||||
return hasSection(content, /§2 Heartbeat/) ? true : 'Missing §2';
|
||||
});
|
||||
|
||||
test('GOVERNANCE.md §3 Blocker Management', () => {
|
||||
const content = read('GOVERNANCE.md');
|
||||
return hasSection(content, /§3 Blocker/) ? true : 'Missing §3';
|
||||
});
|
||||
|
||||
test('GOVERNANCE.md §4 Replanning Trigger', () => {
|
||||
const content = read('GOVERNANCE.md');
|
||||
return hasSection(content, /§4 Replanning/) ? true : 'Missing §4';
|
||||
});
|
||||
|
||||
test('GOVERNANCE.md §5 Scope Explosion', () => {
|
||||
const content = read('GOVERNANCE.md');
|
||||
return hasSection(content, /§5 Scope/) ? true : 'Missing §5';
|
||||
});
|
||||
|
||||
test('GOVERNANCE.md §6 Execution Audit', () => {
|
||||
const content = read('GOVERNANCE.md');
|
||||
return hasSection(content, /§6 Execution/) ? true : 'Missing §6';
|
||||
});
|
||||
|
||||
test('GOVERNANCE.md §7 Health Score (5 dimensions)', () => {
|
||||
const content = read('GOVERNANCE.md');
|
||||
const dims = ['Delivery', 'Quality', 'Parallelism', 'Dependency', 'Learning'];
|
||||
const missing = dims.filter(d => !content.includes(d + ' Score'));
|
||||
return missing.length === 0 ? true : `Missing dimensions: ${missing.join(', ')}`;
|
||||
});
|
||||
|
||||
test('GOVERNANCE.md §8 Auto-Stop Rule', () => {
|
||||
const content = read('GOVERNANCE.md');
|
||||
return hasSection(content, /§8 Auto-Stop/) ? true : 'Missing §8';
|
||||
});
|
||||
|
||||
test('GOVERNANCE.md §9 Quality Gate', () => {
|
||||
const content = read('GOVERNANCE.md');
|
||||
return hasSection(content, /§9 Governance Quality/) ? true : 'Missing §9';
|
||||
});
|
||||
|
||||
// ── 3. PORTFOLIO Compatibility ──
|
||||
console.log(`\n--- 3. PORTFOLIO Compatibility ---\n`);
|
||||
|
||||
test('PORTFOLIO.md exists', () => {
|
||||
const content = read('PORTFOLIO.md');
|
||||
return content.length > 100 ? true : 'File too short';
|
||||
});
|
||||
|
||||
test('PORTFOLIO.md §1 Portfolio Registry', () => {
|
||||
const content = read('PORTFOLIO.md');
|
||||
return hasSection(content, /§1 Portfolio Registry/) ? true : 'Missing §1';
|
||||
});
|
||||
|
||||
test('PORTFOLIO.md §2 Project States (7 states)', () => {
|
||||
const content = read('PORTFOLIO.md');
|
||||
const states = ['PLANNING', 'ACTIVE', 'BLOCKED', 'RECOVERY', 'PAUSED', 'COMPLETED', 'ARCHIVED'];
|
||||
const missing = states.filter(s => !content.includes(s));
|
||||
return missing.length === 0 ? true : `Missing: ${missing.join(', ')}`;
|
||||
});
|
||||
|
||||
test('PORTFOLIO.md §3 Priority System (P0-P3)', () => {
|
||||
const content = read('PORTFOLIO.md');
|
||||
return (content.includes('P0') && content.includes('P1') && content.includes('P2') && content.includes('P3'))
|
||||
? true : 'Missing one or more priority levels';
|
||||
});
|
||||
|
||||
test('PORTFOLIO.md §4 Resource Allocation', () => {
|
||||
const content = read('PORTFOLIO.md');
|
||||
return hasSection(content, /§4 Resource/) ? true : 'Missing §4';
|
||||
});
|
||||
|
||||
test('PORTFOLIO.md §5 Health Aggregation', () => {
|
||||
const content = read('PORTFOLIO.md');
|
||||
return hasSection(content, /§5 Project Health/) ? true : 'Missing §5';
|
||||
});
|
||||
|
||||
test('PORTFOLIO.md §6 Portfolio Health Score', () => {
|
||||
const content = read('PORTFOLIO.md');
|
||||
return hasSection(content, /§6 Portfolio Health/) ? true : 'Missing §6';
|
||||
});
|
||||
|
||||
test('PORTFOLIO.md §7 Learning Reuse', () => {
|
||||
const content = read('PORTFOLIO.md');
|
||||
return hasSection(content, /§7 Learning Reuse/) ? true : 'Missing §7';
|
||||
});
|
||||
|
||||
test('PORTFOLIO.md §8 Portfolio Audit', () => {
|
||||
const content = read('PORTFOLIO.md');
|
||||
return hasSection(content, /§8 Portfolio Audit/) ? true : 'Missing §8';
|
||||
});
|
||||
|
||||
test('PORTFOLIO.md §9 Kill Rule / §10 Pause Rule / §11 Promotion Rule', () => {
|
||||
const content = read('PORTFOLIO.md');
|
||||
return (hasSection(content, /§9 Project Kill/) &&
|
||||
hasSection(content, /§10 Project Pause/) &&
|
||||
hasSection(content, /§11 Project Promotion/))
|
||||
? true : 'Missing one of §9/§10/§11';
|
||||
});
|
||||
|
||||
test('PORTFOLIO.md §12 Executive Dashboard', () => {
|
||||
const content = read('PORTFOLIO.md');
|
||||
return hasSection(content, /§12 Executive Dashboard/) ? true : 'Missing §12';
|
||||
});
|
||||
|
||||
test('PORTFOLIO.md §13 Quality Gate / §14 Integration', () => {
|
||||
const content = read('PORTFOLIO.md');
|
||||
return (hasSection(content, /§13 Portfolio Quality/) &&
|
||||
hasSection(content, /§14 Integration/))
|
||||
? true : 'Missing §13 or §14';
|
||||
});
|
||||
|
||||
// ── 4. Audit Compatibility ──
|
||||
console.log(`\n--- 4. Audit Script Compatibility ---\n`);
|
||||
|
||||
for (const script of ['audit-agents.mjs', 'audit-governance.mjs', 'audit-portfolio.mjs', 'audit-all.mjs']) {
|
||||
const rel = path.join('scripts', script);
|
||||
test(`${script} exists`, () => {
|
||||
return fs.existsSync(path.join(WORKSPACE, rel)) ? true : `Missing: ${rel}`;
|
||||
});
|
||||
|
||||
test(`${script} runs without crash`, () => {
|
||||
const result = runScript(rel);
|
||||
if (result.status === 1 && result.stderr && !result.stderr.includes('FAIL')) {
|
||||
// Check if it's a WARN-only exit (audit scripts exit 0 even on WARN)
|
||||
// Actually, audit-all exits with code 0 always — let's just check for crash
|
||||
}
|
||||
// Crashing would throw — if we're here, it ran
|
||||
return result.status !== null ? true : 'Script did not produce exit code';
|
||||
});
|
||||
}
|
||||
|
||||
// ── 5. Template Compatibility ──
|
||||
console.log(`\n--- 5. Template Compatibility ---\n`);
|
||||
|
||||
const templates = [
|
||||
'progress.log.template', 'blocker.md.template', 'execution-audit.md.template',
|
||||
'recovery-report.md.template', 'portfolio.md.template', 'portfolio-audit.md.template',
|
||||
'dashboard.md.template'
|
||||
];
|
||||
|
||||
for (const tpl of templates) {
|
||||
test(`${tpl} exists`, () => {
|
||||
return fs.existsSync(path.join(WORKSPACE, 'scripts/templates', tpl)) ? true : `Missing: scripts/templates/${tpl}`;
|
||||
});
|
||||
|
||||
test(`${tpl} is valid markdown`, () => {
|
||||
const content = read(`scripts/templates/${tpl}`);
|
||||
if (content.length < 10) return `Template too short: ${content.length} bytes`;
|
||||
// Check for unbalanced code fences
|
||||
const fences = (content.match(/```/g) || []).length;
|
||||
return (fences % 2 === 0) ? true : `Unbalanced code fences (${fences})`;
|
||||
});
|
||||
}
|
||||
|
||||
// ── 6. Cross-Ref Integrity ──
|
||||
console.log(`\n--- 6. Cross-Reference Integrity ---\n`);
|
||||
|
||||
test('AGENTS.md → GOVERNANCE.md references resolve', () => {
|
||||
const content = read('AGENTS.md');
|
||||
const refs = content.match(/GOVERNANCE\.md/g) || [];
|
||||
// All should exist since GOVERNANCE.md exists
|
||||
return fs.existsSync(path.join(WORKSPACE, 'GOVERNANCE.md'))
|
||||
? true
|
||||
: 'GOVERNANCE.md not found (referenced from AGENTS.md)';
|
||||
});
|
||||
|
||||
test('GOVERNANCE.md → PORTFOLIO.md references resolve', () => {
|
||||
const content = read('GOVERNANCE.md');
|
||||
const refs = content.match(/PORTFOLIO\.md/g) || [];
|
||||
return fs.existsSync(path.join(WORKSPACE, 'PORTFOLIO.md'))
|
||||
? true
|
||||
: 'PORTFOLIO.md not found (referenced from GOVERNANCE.md)';
|
||||
});
|
||||
|
||||
test('PORTFOLIO.md → AGENTS.md references resolve', () => {
|
||||
const content = read('PORTFOLIO.md');
|
||||
const refs = content.match(/AGENTS\.md/g) || [];
|
||||
return fs.existsSync(path.join(WORKSPACE, 'AGENTS.md'))
|
||||
? true
|
||||
: 'AGENTS.md not found (referenced from PORTFOLIO.md)';
|
||||
});
|
||||
|
||||
test('No orphan cross-references', () => {
|
||||
const allContent = read('AGENTS.md') + read('GOVERNANCE.md') + read('PORTFOLIO.md');
|
||||
// Check that all .md references point to existing files
|
||||
const refs = allContent.match(/[A-Z][A-Za-z-]*\.md/g) || [];
|
||||
// Filter out date-like patterns (YYYY-MM-DD.md, DD.md)
|
||||
const knownNonFiles = ['YYYY-MM-DD.md', 'DD.md', 'MM.md', 'YYYY.md'];
|
||||
const fileRefs = refs.filter(r => !knownNonFiles.includes(r));
|
||||
const existing = new Set(['AGENTS.md', 'GOVERNANCE.md', 'PORTFOLIO.md', 'MEMORY.md',
|
||||
'HEARTBEAT.md', 'SOUL.md', 'IDENTITY.md', 'USER.md', 'TOOLS.md', 'README.md',
|
||||
'PROJECT-COMPLETION-REPORT.md', 'COMPATIBILITY-STRATEGY.md', 'SYSTEM-AUDIT.md',
|
||||
'version-contract.md', 'upgrade-playbook.md', 'AGENTS-PROTOCOL-FULL.md']);
|
||||
const missing = fileRefs.filter(r => !existing.has(r));
|
||||
return missing.length === 0 ? true : `Possible unresolved refs: ${[...new Set(missing)].join(', ')}`;
|
||||
});
|
||||
|
||||
// ── 7. Version Contract Compliance ──
|
||||
console.log(`\n--- 7. Version Contract Compliance ---\n`);
|
||||
|
||||
test('version-contract.md exists', () => {
|
||||
return fs.existsSync(path.join(WORKSPACE, 'version-contract.md')) ? true : 'Missing version-contract.md';
|
||||
});
|
||||
|
||||
test('Required interfaces are present', () => {
|
||||
const agents = read('AGENTS.md');
|
||||
const hasCtxSearch = agents.includes('ctx_search');
|
||||
const hasMemSearch = agents.includes('memory_search') || agents.includes('memory_get');
|
||||
const hasCtxIndex = agents.includes('ctx_index');
|
||||
// All are OPTIONAL to have — the test is: if they're referenced, they should be expected
|
||||
// This is a soft check
|
||||
return true; // Soft pass — version-contract.md defines the expectation
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log(`Upgrade Test Suite Complete`);
|
||||
console.log(`${'='.repeat(60)}`);
|
||||
console.log(` Total: ${testsTotal}`);
|
||||
console.log(` PASS: ${testsPassed}`);
|
||||
console.log(` FAIL: ${testsFailed}`);
|
||||
console.log(` Verdict: ${testsFailed > 0 ? '❌ FAIL — upgrade may break compatibility' : '✅ PASS — all compatible'}`);
|
||||
console.log();
|
||||
|
||||
process.exit(testsFailed > 0 ? 1 : 0);
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# watchdog.sh — 远程记忆服务器健康监控
|
||||
#
|
||||
# 每2小时执行一次(由 cron 调用)
|
||||
# 连续 3 次失败 → 告警
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/watchdog.sh # 单次检查
|
||||
# bash scripts/watchdog.sh status # 查看历史
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MEMORY_SERVER="http://111.229.145.18"
|
||||
HISTORY_FILE="$HOME/.openclaw/memory/watchdog-history.log"
|
||||
ALERT_FILE="$HOME/.openclaw/memory/watchdog-alert.log"
|
||||
FAIL_COUNT_FILE="$HOME/.openclaw/memory/watchdog-failures"
|
||||
THRESHOLD=3
|
||||
|
||||
mkdir -p "$(dirname "$HISTORY_FILE")"
|
||||
|
||||
check() {
|
||||
local ts=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
local result=""
|
||||
local stats=""
|
||||
local search_test=""
|
||||
|
||||
# 1. 健康检查
|
||||
stats=$(curl -s --max-time 5 "$MEMORY_SERVER/api/v2/stats" 2>/dev/null) || true
|
||||
if [ -z "$stats" ]; then
|
||||
result="DOWN (stats unreachable)"
|
||||
else
|
||||
# 2. 搜索功能检查
|
||||
search_test=$(curl -s --max-time 5 -X POST "$MEMORY_SERVER/api/v2/search" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"健康检查","project":"xiaolong","limit":1}' 2>/dev/null) || true
|
||||
if echo "$search_test" | grep -q 'results'; then
|
||||
result="OK"
|
||||
# 提取记忆数
|
||||
local mem_count=$(echo "$stats" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log((d.projects.find(p=>p.project.includes('xiaolong'))||{}).memories||0)" 2>/dev/null || echo 0)
|
||||
result="OK ($mem_count memories)"
|
||||
else
|
||||
result="DEGRADED (search failed)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$ts | $result" >> "$HISTORY_FILE"
|
||||
|
||||
# 3. 失败计数
|
||||
if echo "$result" | grep -qE 'DOWN|DEGRADED'; then
|
||||
local failures=0
|
||||
[ -f "$FAIL_COUNT_FILE" ] && failures=$(cat "$FAIL_COUNT_FILE")
|
||||
failures=$((failures + 1))
|
||||
echo "$failures" > "$FAIL_COUNT_FILE"
|
||||
|
||||
if [ "$failures" -ge "$THRESHOLD" ]; then
|
||||
echo "$ts | ALERT: $result (连续失败 $failures 次)" >> "$ALERT_FILE"
|
||||
echo "🚨 警告: 远程记忆服务器 $result (连续 $failures 次)"
|
||||
# 可以在这里加 webhook 通知
|
||||
fi
|
||||
echo "⚠️ $result"
|
||||
else
|
||||
echo "0" > "$FAIL_COUNT_FILE"
|
||||
echo "✅ $result"
|
||||
fi
|
||||
|
||||
echo "$ts | watchdog: $result"
|
||||
}
|
||||
|
||||
status() {
|
||||
echo "📊 远程服务器监控历史"
|
||||
echo ""
|
||||
echo "最近 10 次检查:"
|
||||
[ -f "$HISTORY_FILE" ] && tail -10 "$HISTORY_FILE" | sed 's/^/ /' || echo " (无记录)"
|
||||
echo ""
|
||||
echo "失败计数:"
|
||||
[ -f "$FAIL_COUNT_FILE" ] && echo " 连续失败: $(cat "$FAIL_COUNT_FILE") 次" || echo " 0 次"
|
||||
echo ""
|
||||
echo "告警历史:"
|
||||
[ -f "$ALERT_FILE" ] && tail -5 "$ALERT_FILE" | sed 's/^/ /' || echo " (无)"
|
||||
}
|
||||
|
||||
case "${1:-check}" in
|
||||
check) check ;;
|
||||
status) status ;;
|
||||
esac
|
||||
Reference in New Issue
Block a user