File size: 7,674 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 | #!/usr/bin/env python3
import json, subprocess, hashlib
from pathlib import Path
from datetime import datetime, timezone
ROOT = Path(__file__).resolve().parents[1]
TRIAGE = ROOT / 'triage'
E = ROOT / 'inventory' / 'evidence'
JOBS = ROOT / 'jobs' / 'pending'
OUT = TRIAGE / 'READ_ONLY_AUDIT.md'
OUT_JSON = TRIAGE / 'READ_ONLY_AUDIT.json'
REQUIRED_TRIAGE = [
'PROJECT_PRIORITIZATION.md', 'PROJECT_PRIORITIZATION.json', 'PROJECT_PRIORITIZATION_SUMMARY.json',
'KEEP.md', 'MERGE.md', 'PAUSE.md', 'ARCHIVE.md', 'URGENT.md',
'TOKEN_BURN_CANDIDATE.md', 'HUMAN_DECISION_REQUIRED.md', 'TOP_10_NEXT_ACTIONS.md'
]
REQUIRED_EVIDENCE = [
'git_repos.json', 'readmes.json', 'todos_fixmes.json', 'old_plans.json',
'uncommitted_changes.json', 'open_branches.json', 'tests.json',
'broken_projects.json', 'duplicate_projects.json', 'theme_clusters.json'
]
FORBIDDEN_JOB_WORDS = ['commit', 'push', 'merge', 'delete', 'rm -rf', 'create github issues', 'gh issue create']
REQUIRED_JOB_WORDS = ['read-only', 'projectintaketower']
def sh(args, cwd=None, timeout=10):
try:
p = subprocess.run(args, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
return {'exit_code': p.returncode, 'stdout': p.stdout.strip(), 'stderr': p.stderr.strip()}
except Exception as e:
return {'exit_code': 124, 'stdout': '', 'stderr': str(e)}
def sha(path):
h=hashlib.sha256()
with open(path,'rb') as f:
for chunk in iter(lambda:f.read(65536), b''):
h.update(chunk)
return h.hexdigest()
def load_json(path):
try:
return json.loads(path.read_text(encoding='utf-8'))
except Exception as e:
return {'__error__': str(e)}
def main():
checks=[]; failures=[]; warnings=[]
for name in REQUIRED_TRIAGE:
p=TRIAGE/name
ok=p.exists() and p.stat().st_size>0
checks.append({'check':'triage_artifact_exists','target':name,'ok':ok,'size':p.stat().st_size if p.exists() else 0})
if not ok: failures.append(f'missing/empty triage artifact: {name}')
for name in REQUIRED_EVIDENCE:
p=E/name
ok=p.exists() and p.stat().st_size>0
checks.append({'check':'evidence_artifact_exists','target':name,'ok':ok,'size':p.stat().st_size if p.exists() else 0})
if not ok: failures.append(f'missing/empty evidence artifact: {name}')
elif p.suffix == '.json':
data=load_json(p)
if isinstance(data, dict) and '__error__' in data:
failures.append(f'invalid json: {name}: {data["__error__"]}')
summary=load_json(TRIAGE/'PROJECT_PRIORITIZATION_SUMMARY.json')
prioritization=load_json(TRIAGE/'PROJECT_PRIORITIZATION.json')
if isinstance(summary, dict) and isinstance(prioritization, dict) and 'categories' in prioritization:
for cat,count in summary.items():
if cat == 'total_projects': continue
actual=len(prioritization['categories'].get(cat, []))
ok=actual==count
checks.append({'check':'category_count_matches','target':cat,'ok':ok,'expected':count,'actual':actual})
if not ok: failures.append(f'category count mismatch {cat}: expected {count}, actual {actual}')
else:
failures.append('cannot validate prioritization summary/categories')
# Audit pending jobs for review-only shape.
job_files=sorted(JOBS.glob('*.json'))
bad_jobs=[]
for jp in job_files:
data=load_json(jp)
text=jp.read_text(encoding='utf-8', errors='ignore').lower()
# Dangerous words are allowed only when they appear in forbidden_actions.
fwords=' '.join(data.get('forbidden_actions',[]) + data.get('scope',{}).get('forbidden_actions',[])).lower() if isinstance(data,dict) else ''
awords=' '.join(data.get('allowed_files',[]) + data.get('scope',{}).get('allowed_paths',[])).lower() if isinstance(data,dict) else ''
allowed_actions=' '.join(data.get('allowed_actions',[]) + data.get('scope',{}).get('allowed_actions',[])).lower() if isinstance(data,dict) else ''
mode=data.get('mode') if isinstance(data,dict) else None
problems=[]
if mode and mode != 'review_only': problems.append(f'mode={mode}')
for word in ['commit','push','secret']:
if word not in fwords: problems.append(f'forbidden missing {word}')
if 'projectintaketower' not in awords and 'projectintaketower' not in text: problems.append('missing ProjectIntakeTower artifact scope')
dangerous_allowed=[w for w in ['commit','push','merge','delete','rm -rf','create github issues','gh issue create'] if w in allowed_actions]
if dangerous_allowed: problems.append('dangerous allowed_action: '+','.join(dangerous_allowed))
if problems: bad_jobs.append({'path':str(jp),'problems':problems})
if bad_jobs:
warnings.append(f'{len(bad_jobs)} pending jobs need review-only contract cleanup')
checks.append({'check':'pending_jobs_audited','target':'jobs/pending','ok':not bad_jobs,'jobs':len(job_files),'bad_jobs':len(bad_jobs)})
# Git state of ProjectIntakeTower repo itself after generated artifacts.
git_status=sh(['git','status','--porcelain=v1'], cwd=ROOT.parents[0])
checks.append({'check':'current_repo_git_status_read','target':str(ROOT.parents[0]),'ok':git_status['exit_code']==0,'dirty_lines':len(git_status['stdout'].splitlines()) if git_status['stdout'] else 0})
if git_status['exit_code'] != 0:
warnings.append('could not read git status for current workspace')
# Hash key outputs for stable handoff.
hashes={}
for name in REQUIRED_TRIAGE + REQUIRED_EVIDENCE:
p=(TRIAGE/name) if (TRIAGE/name).exists() else (E/name)
if p.exists() and p.is_file(): hashes[str(p.relative_to(ROOT))]=sha(p)
status='passed' if not failures else 'failed'
result={
'status': status,
'timestamp': datetime.now(timezone.utc).isoformat(),
'objective': 'READ_ONLY_AUDIT of ProjectIntakeTower prioritization/intake artifacts',
'checks': checks,
'failures': failures,
'warnings': warnings,
'bad_jobs': bad_jobs[:100],
'hashes': hashes,
'next_action': 'Review READ_ONLY_AUDIT.md; fix warnings only if Samuel wants stricter job-contract hygiene.' if status=='passed' else 'Fix failures before trusting prioritization.'
}
OUT_JSON.write_text(json.dumps(result,indent=2,ensure_ascii=False),encoding='utf-8')
lines=[
'# READ_ONLY_AUDIT', '',
f'Status: {status}',
f'Timestamp: {result["timestamp"]}', '',
'Scope: ProjectIntakeTower artifacts only. No source-project edits, no commits, no GitHub issue creation.', '',
'## Summary',
f'- Checks: {len(checks)}',
f'- Failures: {len(failures)}',
f'- Warnings: {len(warnings)}',
f'- Pending jobs audited: {len(job_files)}',
f'- Bad job contracts: {len(bad_jobs)}', '',
'## Failures'
]
lines += [f'- {x}' for x in failures] or ['- none']
lines += ['', '## Warnings']
lines += [f'- {x}' for x in warnings] or ['- none']
lines += ['', '## Category counts']
if isinstance(summary, dict):
for k,v in summary.items(): lines.append(f'- {k}: {v}')
lines += ['', '## Next action', result['next_action'], '', 'JSON detail:', f'`{OUT_JSON}`']
OUT.write_text('\n'.join(lines)+'\n',encoding='utf-8')
print(json.dumps({'status':status,'failures':len(failures),'warnings':len(warnings),'bad_jobs':len(bad_jobs),'out':str(OUT)},indent=2))
raise SystemExit(0 if status=='passed' else 1)
if __name__=='__main__': main()
|