#!/usr/bin/env python3 import json, re from pathlib import Path from collections import defaultdict ROOT = Path(__file__).resolve().parents[1] INV = ROOT / 'inventory' E = INV / 'evidence' T = ROOT / 'triage' T.mkdir(parents=True, exist_ok=True) CATEGORIES = ['KEEP','MERGE','PAUSE','ARCHIVE','URGENT','TOKEN_BURN_CANDIDATE','HUMAN_DECISION_REQUIRED'] def load_json(name, default): p = E / name if not p.exists(): return default return json.loads(p.read_text(encoding='utf-8')) def load_projects(): rows=[] p=INV/'projects.jsonl' for line in p.read_text(encoding='utf-8').splitlines(): if line.strip(): try: rows.append(json.loads(line)) except Exception: pass seen={}; out=[] for r in rows: path=r.get('path') or '' if not path or path in seen: continue seen[path]=1; out.append(r) return out def norm_path(p): return str(p or '') def basename(path): return Path(path).name or path.rstrip('/').split('/')[-1] def is_cache(path): s=path.lower() return any(x in s for x in ['/repo_cache/','/eval/fixtures/','/codex-skill-validation-fixtures/','/node_modules/','.venv']) def theme_map(theme_rows): m=defaultdict(set) for row in theme_rows: m[row.get('repo','')].update(row.get('themes',[])) return m def main(): projects=load_projects() uncomm={x['repo']:x for x in load_json('uncommitted_changes.json',[])} branches={x['repo']:x for x in load_json('open_branches.json',[])} broken={x['repo']:x for x in load_json('broken_projects.json',[])} dups=load_json('duplicate_projects.json',[]) themes=theme_map(load_json('theme_clusters.json',[])) dup_paths={p:row for row in dups for p in row.get('repos',[])} rows=[] buckets={c:[] for c in CATEGORIES} for p in projects: path=norm_path(p.get('path')) name=p.get('name') or basename(path) score=int(p.get('score') or 0) todo=int(p.get('todo_fixme_count') or 0) plan=int(p.get('plan_hit_count') or 0) agent=int(p.get('agent_artifact_count') or 0) readme=bool(p.get('readme_path') or p.get('readme_summary')) dirty=uncomm.get(path,{}).get('count',0) or (p.get('git_status') or {}).get('dirty_tracked_count',0) or 0 branch_count=len(branches.get(path,{}).get('branches',[])) is_dup=path in dup_paths th=themes.get(path,set()) tech=','.join(p.get('technologies',[]) or p.get('project_type',[]) or ['unknown']) reasons=[] cats=[] # Priority rules: categories are non-exclusive. if dirty >= 10 or (dirty >= 5 and th) or path in ['/home/smlflg/.hermes','/home/smlflg/.claude','/home/smlflg/.codex']: cats.append('URGENT'); reasons.append(f'uncommitted_changes={dirty}') if dirty or branch_count > 1 or p.get('risk_level') == 'high' or path in broken: cats.append('HUMAN_DECISION_REQUIRED'); reasons.append('state/risk needs owner decision') if is_dup: cats.append('MERGE'); reasons.append('duplicate cluster') if is_cache(path) or re.search(r'(-old|backup|copy|repo_cache|fixture|astropy__|django__|sympy__)', path, re.I): cats.append('ARCHIVE'); reasons.append('cache/fixture/old-artifact signal') # token burn: many plans/todos/agent artifacts but weak closure/readme or duplicated meta system. if (plan >= 5 and todo >= 20) or (agent >= 5 and dirty >= 5) or (is_dup and score >= 50): cats.append('TOKEN_BURN_CANDIDATE'); reasons.append(f'plan={plan}, todo={todo}, agent_artifacts={agent}, score={score}') keep_signal = score >= 65 or bool(th & {'agent_control_tower_near','hermes_near','uni_near'}) or readme and score >= 50 if keep_signal and 'ARCHIVE' not in cats: cats.append('KEEP'); reasons.append('high score/theme/readme signal') if not cats or ('KEEP' not in cats and 'MERGE' not in cats and 'ARCHIVE' not in cats and score < 50): cats.append('PAUSE'); reasons.append('low confidence or not top-level valuable now') # If KEEP plus MERGE, the repo is valuable but should be merged/consolidated, not developed separately. categories=list(dict.fromkeys(cats)) row={ 'name': name, 'path': path, 'categories': categories, 'score': score, 'dirty_count': dirty, 'branch_count': branch_count, 'todo_fixme_count': todo, 'plan_hit_count': plan, 'agent_artifact_count': agent, 'themes': sorted(th), 'duplicate_cluster': dup_paths.get(path,{}).get('key'), 'readme_present': readme, 'tech': tech, 'reasons': reasons[:8] } rows.append(row) for c in categories: buckets[c].append(row) # Sort within buckets: urgent by dirty, keep by score, archive by cache-ish path, pause low score. for c in CATEGORIES: buckets[c].sort(key=lambda r: (-(r['dirty_count'] if c=='URGENT' else r['score']), r['path'])) (T/'PROJECT_PRIORITIZATION.json').write_text(json.dumps({'categories':buckets,'projects':rows},indent=2,ensure_ascii=False),encoding='utf-8') md=['# Project Prioritization','','Categories are non-exclusive. KEEP+MERGE means valuable but consolidate instead of building separately. URGENT/HUMAN_DECISION means do not delegate blindly.',''] for c in CATEGORIES: md.append(f'## {c} ({len(buckets[c])})') for r in buckets[c][:80]: extra=[] if r['dirty_count']: extra.append(f"dirty={r['dirty_count']}") if r['branch_count']>1: extra.append(f"branches={r['branch_count']}") if r['duplicate_cluster']: extra.append(f"dup={r['duplicate_cluster']}") if r['themes']: extra.append('themes='+','.join(r['themes'])) extra.append(f"score={r['score']}") md.append(f"- `{r['path']}` — {', '.join(extra)} — {('; '.join(r['reasons']))[:220]}") if len(buckets[c])>80: md.append(f'- ... {len(buckets[c])-80} more in JSON') md.append('') (T/'PROJECT_PRIORITIZATION.md').write_text('\n'.join(md),encoding='utf-8') # one-file-per-category for quick opening for c in CATEGORIES: lines=[f'# {c}', '', f'Count: {len(buckets[c])}', ''] for r in buckets[c]: lines.append(f"## {r['name']}") lines.append(f"Path: `{r['path']}`") lines.append(f"Score: {r['score']} | Dirty: {r['dirty_count']} | Branches: {r['branch_count']} | TODO/FIXME: {r['todo_fixme_count']} | Plans: {r['plan_hit_count']}") lines.append(f"Categories: {', '.join(r['categories'])}") if r['themes']: lines.append(f"Themes: {', '.join(r['themes'])}") if r['duplicate_cluster']: lines.append(f"Duplicate cluster: {r['duplicate_cluster']}") lines.append('Reasons:') for reason in r['reasons']: lines.append(f'- {reason}') lines.append('') (T/f'{c}.md').write_text('\n'.join(lines),encoding='utf-8') top10=[] for r in buckets['URGENT'][:5]: top10.append(('Stabilize dirty repo before more work', r)) for r in buckets['MERGE'][:3]: top10.append(('Decide consolidation target', r)) for r in buckets['KEEP'][:5]: if all(x[1]['path']!=r['path'] for x in top10): top10.append(('Keep and assign focused verifier', r)) top10=top10[:10] lines=['# Top 10 Next Actions — Prioritized Categories','', '| Rank | Action | Category | Project | Why | Human gate |','|---:|---|---|---|---|---|'] for i,(action,r) in enumerate(top10,1): cats=', '.join(r['categories']) why=', '.join(r['reasons'])[:180] gate='yes' if any(c in r['categories'] for c in ['URGENT','HUMAN_DECISION_REQUIRED','MERGE','ARCHIVE']) else 'no' lines.append(f"| {i} | {action} | {cats} | `{r['path']}` | {why} | {gate} |") (T/'TOP_10_NEXT_ACTIONS.md').write_text('\n'.join(lines)+'\n',encoding='utf-8') summary={c:len(buckets[c]) for c in CATEGORIES} summary['total_projects']=len(rows) (T/'PROJECT_PRIORITIZATION_SUMMARY.json').write_text(json.dumps(summary,indent=2),encoding='utf-8') print(json.dumps(summary,indent=2)) if __name__=='__main__': main()