File size: 8,233 Bytes
b67a1e9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | #!/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()
|