340 lines
15 KiB
JavaScript
340 lines
15 KiB
JavaScript
#!/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();
|
||
}
|
||
}
|