The henxels allowed_filetypes rule polices top-level ./docs/* for markdown/yaml only; machine-generated JSON artifacts are not docs. Moving rebase-inventory.json and rebase-wave-coverage.json under docs/rebase/ (outside the single-segment glob) rather than widening the gate's except list to fit new files. All consumer references updated (rebase_inventory.py, rebase_issue_factory.py, wave0 report).
357 lines
14 KiB
Python
357 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
"""Tranche R1 Wave 0 — per-function issue factory.
|
||
|
||
Reads docs/rebase/rebase-inventory.json and emits one issue per unit (top-level
|
||
function or class method) following tranche R1 §7. Wave-batched, dedup-safe
|
||
(deterministic title + existing-issue scan before create), rate-limited.
|
||
|
||
Modes:
|
||
plan — coverage report: units per wave, unassigned modules (no silent drops)
|
||
sample — emit N rendered issues to a file for review (no Gitea call)
|
||
create — create issues in Gitea for one wave
|
||
|
||
Dedup contract: issue titles are deterministic —
|
||
[rebase w{wave}] {relpath}::{unit}
|
||
The factory fetches existing issue titles once per run and skips any that
|
||
already exist, so retries never duplicate (preserve-first Gitea addendum).
|
||
|
||
The factory NEVER writes behaviour prose. Current-behaviour paragraphs and
|
||
the three evidence answers (IMPROVE / FIX / MISSING CONNECTORS) are filled at
|
||
disposition time by the working agent — the issue carries the mandatory
|
||
fields and line ranges, not fabricated content.
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
import time
|
||
import urllib.request
|
||
import urllib.error
|
||
from pathlib import Path
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||
INVENTORY = REPO_ROOT / "docs" / "rebase" / "rebase-inventory.json"
|
||
COVERAGE_OUT = REPO_ROOT / "docs" / "rebase" / "rebase-wave-coverage.json"
|
||
|
||
GITEA_URL = os.environ.get("GITEA_URL", "https://gitea.thingscouldgetdicey.online")
|
||
REPO = "conductor/rune"
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "shared"))
|
||
from gitea_auth import load_gitea_token # noqa: E402
|
||
|
||
TRANCHE_REF = "tranche R1 language rebase (2026-09-29), ~/.config/opencode/tranches/2026-09-29-language-rebase-tranche-R1.md"
|
||
|
||
# Wave membership from R1 §4 (initial, measured). Directory entries match by
|
||
# path prefix; bare stems match the file stem. Modules matching no entry are
|
||
# reported UNASSIGNED by `plan` — never silently dropped (R1 §9 risk 7).
|
||
WAVE_PLAN = {
|
||
2: [ # leaf utilities & canaries
|
||
"pins", "time_awareness", "message_queue", "event_envelope",
|
||
"attention_capsule", "complexity_scorer", "token_optimizer", "cache",
|
||
"prompt_sync", "chat_bubble", "canary_*",
|
||
],
|
||
3: [ # memory & persistence
|
||
"memory_store", "curator", "warm_router", "memory_consolidation",
|
||
"graph_memory", "sleep_time", "night_mode", "identity_anchors",
|
||
"query_memory", "memory_env", "warm_neuron_store", "subconscious_pass",
|
||
"task_persist",
|
||
],
|
||
4: [ # provider/model layer
|
||
"provider_registry", "model_connect", "model_routing", "model_profiles",
|
||
"provider_budget", "standardized_providers", "model_registry",
|
||
"connection_pool", "forge_client",
|
||
],
|
||
5: [ # orchestration, authority, plugins, skills, governance
|
||
"orchestration/", "microkernel", "contracts", "build_mode", "plugins/",
|
||
"skills/", "construction_authority", "construction_authority_cli",
|
||
"construction_decision", "construction_mode", "verification_construction",
|
||
"authority_recovery", "spine", "drift_core", "drift_ledger",
|
||
"drift_observability", "persona_gate", "safety_override", "witness",
|
||
"behavior", "growth_delta", "claim_audit", "verification_layer",
|
||
"drift_compression", "drift_cache", "self_improve", "persona_definition",
|
||
"outcome_ledger", "audit_ledger", "subsystem_health",
|
||
],
|
||
6: [ # runtime core & tooling
|
||
"tool_executor", "tool_protocol", "local_tool_runtime",
|
||
"capability_broker", "context_broker", "work_manager", "cron_engine",
|
||
"auto_tasks", "goal", "failure_auto_filer", "hook_engine",
|
||
"elastic_cognition", "event_bus", "system_registry", "abstraction",
|
||
"tiered_executor", "progressive_escalation", "tier_manager",
|
||
"parallel_processor", "shard_manager", "response_adapter",
|
||
"context_pressure", "auto_context", "cognitive_budgeter",
|
||
"recall_router", "inference_trace", "task_gate", "salience_gate",
|
||
"dependency_tracker", "rune_profiler",
|
||
],
|
||
7: [ # operator + TUI, last by design
|
||
"rune_operator", "rune_tui", "tui_validator", "voice_hybrid",
|
||
"voice_sync", "voicebox", "voice_injector", "message_widgets",
|
||
"rune_graphic", "rune_spinner", "__main__",
|
||
],
|
||
}
|
||
|
||
WAVE_NAMES = {
|
||
2: "leaf utilities & canaries",
|
||
3: "memory & persistence",
|
||
4: "provider/model layer",
|
||
5: "orchestration, authority, plugins, skills, governance",
|
||
6: "runtime core & tooling",
|
||
7: "operator + TUI",
|
||
}
|
||
|
||
|
||
def match_module(relpath: str):
|
||
"""Return the wave number for a module path, or None if unassigned."""
|
||
stem = Path(relpath).stem
|
||
for wave, patterns in WAVE_PLAN.items():
|
||
for pat in patterns:
|
||
if pat.endswith("/"):
|
||
if pat in relpath:
|
||
return wave
|
||
elif pat.endswith("*"):
|
||
if stem.startswith(pat[:-1]):
|
||
return wave
|
||
elif stem == pat:
|
||
return wave
|
||
return None
|
||
|
||
|
||
def iter_units(inv):
|
||
"""Yield (relpath, unit_name, entry) for every function and method."""
|
||
for relpath, mod in sorted(inv["files"].items()):
|
||
for fn in mod["functions"]:
|
||
yield relpath, fn["name"], fn
|
||
for cls in mod["classes"]:
|
||
for m in cls.get("methods", []):
|
||
yield relpath, f"{cls['name']}.{m['name']}", m
|
||
|
||
|
||
def internal_edges(relpath: str, entry: dict):
|
||
"""Collect internal (in-repo) call names and line numbers for a unit."""
|
||
lines = (entry["line_start"], entry["line_end"])
|
||
mod = inv_cache["files"][relpath]
|
||
calls = [c for c in mod.get("calls", [])
|
||
if lines[0] <= c["line"] <= lines[1] and c.get("internal")]
|
||
return calls
|
||
|
||
|
||
inv_cache = {}
|
||
|
||
|
||
def issue_title(wave: int, relpath: str, unit: str) -> str:
|
||
return f"[rebase w{wave}] {relpath}::{unit}"
|
||
|
||
|
||
def render_issue(wave: int, relpath: str, unit: str, entry: dict) -> dict:
|
||
calls = internal_edges(relpath, entry)
|
||
call_lines = "\n".join(
|
||
f" - `{c['call']}` (line {c['line']})" for c in calls[:20]
|
||
) or " - (no internal call edges in range)"
|
||
sig = entry.get("signature", "()")
|
||
body = f"""\
|
||
|
||
## Identity (from the Wave 0 inventory — machine-generated)
|
||
|
||
- **File**: `{relpath}`
|
||
- **Unit**: `{unit}`
|
||
- **Signature**: `def {unit}{sig}` (async: {entry.get('async', False)})
|
||
- **Line range**: {entry['line_start']}–{entry['line_end']}
|
||
- **Decorators**: {entry.get('decorators', []) or 'none'}
|
||
- **Internal call edges** (callers/callees inside the repo, from inventory):
|
||
{call_lines}
|
||
|
||
## Current behaviour
|
||
|
||
One honest paragraph from READING THE CODE at the line range above — what it
|
||
actually does, not what the docstring promises. Write this at disposition
|
||
time; the factory does not fabricate it.
|
||
|
||
## The three questions — each answered WITH EVIDENCE
|
||
|
||
1. **IMPROVE?** Can the unit be improved within its scope? Evidence or "no — because …".
|
||
(Bounded to unit scope; larger findings spawn NEW issues, never expand this one.)
|
||
2. **FIX?** Is it broken, buggy, or failing its expected outcome? Evidence: a failing
|
||
test, a reproduced fault, or a code-level defect citation. A "no" needs evidence too.
|
||
3. **MISSING CONNECTORS?** Are there expected outcomes this unit should connect to but
|
||
doesn't (unwired events, dead parameters, promised-but-absent calls)? Evidence required.
|
||
|
||
A silent skip on any question is a factory bug — answer all three.
|
||
|
||
## Language verdict (per tranche R1 §6 rubric — evidence beats instinct)
|
||
|
||
Choose exactly one, with the evidence line:
|
||
- **KEEP-PYTHON** — one sentence of justification.
|
||
- **RECODE-<lang>** — rubric scores (all 7 dimensions) + benchmark-gate plan: the compiled
|
||
replacement must beat the Python original on the MEASURED hot path, or the recode is
|
||
rejected and recorded. No win, no recode.
|
||
- **MERGE-into:<module>** — name the target and why.
|
||
- **DEPRECATE** — name the caller that no longer needs it.
|
||
|
||
Go is NOT pre-decided. Python is not pre-decided. The rubric decides.
|
||
|
||
## Constraint context
|
||
|
||
- henxels structural contract: this unit's module ≤ 1,200 lines (or on the debt table's
|
||
exception list — check before adding lines).
|
||
- Spine: any module-surface change requires `store/spine/current.json` regen with the code
|
||
change (`test_manifest_maps_every_module` is the arbiter for new top-level modules).
|
||
- Debt table: docs/structural-contract.md.
|
||
|
||
## Pipeline plan
|
||
|
||
1. Branch `issue-<N>` from current master (existing issue-N convention).
|
||
2. Scaffolding activation for any construction (construction_mode/decision per unit change).
|
||
3. Research rounds are MANDATORY for any RECODE verdict (research-stage-headless; ≥2
|
||
live-verified external sources per claim; per-source lessons; kill test per candidate).
|
||
4. Implementation behind the interop contract (R1 §5): compiled units are standalone
|
||
binaries — argv in, JSON on stdout, non-zero exit on failure, `--version` mandatory;
|
||
Python wraps in a thin adapter; store stays the shared truth.
|
||
5. Local gates: pytest + ruff + spine-store regen (if surface changed) + henxels.
|
||
6. PR → pipeline-gate + ci + required checks → merge-robot → auto-version.
|
||
|
||
## Verification
|
||
|
||
- The specific test proving this unit's change: (write at disposition).
|
||
- The keep-green set it must not break: full suite must stay green; named
|
||
keep-green tests for this unit's callers.
|
||
|
||
## Provenance
|
||
|
||
- Tranche: {TRANCHE_REF}
|
||
- Wave: {wave} ({WAVE_NAMES.get(wave, 'unassigned')})
|
||
- Inventory: docs/rebase/rebase-inventory.json (regenerate with scripts/rebase_inventory.py — never hand-edit)
|
||
- Factory: scripts/rebase_issue_factory.py
|
||
"""
|
||
return {
|
||
"title": issue_title(wave, relpath, unit),
|
||
"body": body,
|
||
"labels": ["rebase", f"wave-{wave}"],
|
||
"meta": {
|
||
"wave": wave,
|
||
"file": relpath,
|
||
"unit": unit,
|
||
"line_start": entry["line_start"],
|
||
"line_end": entry["line_end"],
|
||
"async": entry.get("async", False),
|
||
},
|
||
}
|
||
|
||
|
||
def fetch_existing_titles() -> set:
|
||
"""Fetch all issue titles in the repo (paginated) for dedup."""
|
||
token = load_gitea_token()
|
||
titles = set()
|
||
page = 1
|
||
while True:
|
||
url = (f"{GITEA_URL}/api/v1/repos/{REPO}/issues"
|
||
f"?state=all&type=issues&limit=50&page={page}")
|
||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||
batch = json.loads(resp.read())
|
||
if not batch:
|
||
break
|
||
titles.update(i["title"] for i in batch)
|
||
if len(batch) < 50:
|
||
break
|
||
page += 1
|
||
return titles
|
||
|
||
|
||
def create_issue(entry: dict) -> dict:
|
||
token = load_gitea_token()
|
||
url = f"{GITEA_URL}/api/v1/repos/{REPO}/issues"
|
||
payload = json.dumps({
|
||
"title": entry["title"],
|
||
"body": entry["body"],
|
||
"labels": [], # label NAMES are set via the labels endpoint by auto-label; keep create minimal
|
||
}).encode()
|
||
req = urllib.request.Request(url, data=payload, method="POST",
|
||
headers={"Authorization": f"token {token}",
|
||
"Content-Type": "application/json"})
|
||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||
return json.loads(resp.read())
|
||
|
||
|
||
def cmd_plan(inv):
|
||
counts = {}
|
||
unassigned_modules = {}
|
||
for relpath, unit, entry in iter_units(inv):
|
||
wave = match_module(relpath)
|
||
counts[wave] = counts.get(wave, 0) + 1
|
||
if wave is None:
|
||
unassigned_modules.setdefault(relpath, inv["files"][relpath]["lines"])
|
||
report = {
|
||
"units_per_wave": {str(k if k is not None else "UNASSIGNED"): v
|
||
for k, v in sorted(counts.items(), key=lambda kv: str(kv[0]))},
|
||
"unassigned_modules": dict(sorted(unassigned_modules.items(), key=lambda kv: -kv[1])),
|
||
"unassigned_module_count": len(unassigned_modules),
|
||
}
|
||
print(json.dumps(report, indent=1))
|
||
COVERAGE_OUT.write_text(json.dumps(report, indent=1) + "\n")
|
||
print(f"\ncoverage written to {COVERAGE_OUT.relative_to(REPO_ROOT)}", file=sys.stderr)
|
||
return 0 if not unassigned_modules else 1
|
||
|
||
|
||
def cmd_sample(inv, wave, limit, out):
|
||
emitted = 0
|
||
records = []
|
||
for relpath, unit, entry in iter_units(inv):
|
||
if match_module(relpath) != wave:
|
||
continue
|
||
records.append(render_issue(wave, relpath, unit, entry))
|
||
emitted += 1
|
||
if emitted >= limit:
|
||
break
|
||
Path(out).write_text("\n".join(json.dumps(r) for r in records) + "\n")
|
||
print(f"sampled {emitted} issues for wave {wave} -> {out}", file=sys.stderr)
|
||
return 0
|
||
|
||
|
||
def cmd_create(inv, wave, limit, dry_gap):
|
||
existing = fetch_existing_titles()
|
||
created, skipped = 0, 0
|
||
for relpath, unit, entry in iter_units(inv):
|
||
if match_module(relpath) != wave:
|
||
continue
|
||
title = issue_title(wave, relpath, unit)
|
||
if title in existing:
|
||
skipped += 1
|
||
continue
|
||
issue = create_issue(render_issue(wave, relpath, unit, entry))
|
||
created += 1
|
||
print(f"created #{issue.get('number')}: {title}", file=sys.stderr)
|
||
if limit and created >= limit:
|
||
break
|
||
time.sleep(dry_gap)
|
||
print(f"\nwave {wave}: created={created} skipped(dedup)={skipped}", file=sys.stderr)
|
||
return 0
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description=__doc__)
|
||
ap.add_argument("command", choices=["plan", "sample", "create"])
|
||
ap.add_argument("--wave", type=int, help="wave number (2-7)")
|
||
ap.add_argument("--limit", type=int, default=0, help="max issues for sample/create (0 = unlimited; sample defaults to 5)")
|
||
ap.add_argument("--out", default=None, help="sample output file")
|
||
ap.add_argument("--gap", type=float, default=0.5, help="seconds between creates")
|
||
args = ap.parse_args()
|
||
|
||
inv_cache.update(json.loads(INVENTORY.read_text()))
|
||
inv = inv_cache
|
||
|
||
if args.command == "plan":
|
||
return cmd_plan(inv)
|
||
if args.command == "sample":
|
||
if not args.wave:
|
||
ap.error("--wave required")
|
||
out = args.out or f"docs/rebase-factory-sample-w{args.wave}.json"
|
||
return cmd_sample(inv, args.wave, args.limit or 5, out)
|
||
if args.command == "create":
|
||
if not args.wave:
|
||
ap.error("--wave required")
|
||
return cmd_create(inv, args.wave, args.limit, args.gap)
|
||
return 2
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|