@tamng0905/builder-essential-skills 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.claude/agents/lead-builder.md +55 -0
  2. package/.claude/agents/lead-reviewer.md +39 -0
  3. package/README.md +276 -0
  4. package/assets/readme-hero.png +0 -0
  5. package/assets/skill-cards/async-learning-teacher.svg +7 -0
  6. package/assets/skill-cards/code-standards.svg +7 -0
  7. package/assets/skill-cards/lead-research.svg +7 -0
  8. package/assets/skill-cards/lead.svg +7 -0
  9. package/assets/skill-cards/orwell-writing.svg +7 -0
  10. package/assets/skill-cards/session-profiler.svg +7 -0
  11. package/assets/skill-cards/validate-market.svg +7 -0
  12. package/assets/skill-cards/write-blog.svg +7 -0
  13. package/bin/builder-essential-skills.js +134 -0
  14. package/install.ps1 +58 -0
  15. package/install.sh +63 -0
  16. package/package.json +24 -0
  17. package/skills/async-learning-teacher/README.md +50 -0
  18. package/skills/async-learning-teacher/SKILL.md +192 -0
  19. package/skills/async-learning-teacher/agents/openai.yaml +4 -0
  20. package/skills/code-standards/README.md +27 -0
  21. package/skills/code-standards/SKILL.md +51 -0
  22. package/skills/lead/README.md +29 -0
  23. package/skills/lead/SKILL.md +222 -0
  24. package/skills/lead/config.py +359 -0
  25. package/skills/lead/dispatch.md +342 -0
  26. package/skills/lead/loop.md +103 -0
  27. package/skills/lead/models.json +34 -0
  28. package/skills/lead/research.md +76 -0
  29. package/skills/lead/status.ps1 +114 -0
  30. package/skills/lead/status.sh +110 -0
  31. package/skills/lead/watchdog.ps1 +90 -0
  32. package/skills/lead/watchdog.sh +88 -0
  33. package/skills/lead-research/README.md +27 -0
  34. package/skills/lead-research/SKILL.md +157 -0
  35. package/skills/lead-research/tactics.md +150 -0
  36. package/skills/orwell-writing/README.md +27 -0
  37. package/skills/orwell-writing/SKILL.md +44 -0
  38. package/skills/orwell-writing/agents/openai.yaml +4 -0
  39. package/skills/session-profiler/README.md +25 -0
  40. package/skills/session-profiler/SKILL.md +103 -0
  41. package/skills/session-profiler/agents/openai.yaml +4 -0
  42. package/skills/session-profiler/scripts/requirements.txt +2 -0
  43. package/skills/session-profiler/scripts/session_profiler/__init__.py +5 -0
  44. package/skills/session-profiler/scripts/session_profiler/analyses.py +246 -0
  45. package/skills/session-profiler/scripts/session_profiler/cli.py +77 -0
  46. package/skills/session-profiler/scripts/session_profiler/dataset.py +369 -0
  47. package/skills/session-profiler/scripts/session_profiler/discover.py +191 -0
  48. package/skills/session-profiler/scripts/session_profiler/toc.py +74 -0
  49. package/skills/session-profiler/scripts/session_profiler/trace.py +146 -0
  50. package/skills/session-profiler/scripts/sp +20 -0
  51. package/skills/session-profiler/scripts/tests/codex_home/sessions/2026/01/01/rollout-child.jsonl +7 -0
  52. package/skills/session-profiler/scripts/tests/codex_home/sessions/2026/01/01/rollout-root.jsonl +10 -0
  53. package/skills/session-profiler/scripts/tests/fixture/main/subagents/agent-abc.jsonl +2 -0
  54. package/skills/session-profiler/scripts/tests/fixture/main/subagents/agent-abc.meta.json +1 -0
  55. package/skills/session-profiler/scripts/tests/fixture/main.jsonl +7 -0
  56. package/skills/session-profiler/scripts/tests/test_fixture.py +58 -0
  57. package/skills/validate-market/README.md +27 -0
  58. package/skills/validate-market/SKILL.md +202 -0
  59. package/skills/write-blog/README.md +27 -0
  60. package/skills/write-blog/SKILL.md +142 -0
  61. package/skills/write-blog/check.sh +52 -0
  62. package/skills/write-blog/reference/angles.md +51 -0
  63. package/skills/write-blog/reference/craft.md +94 -0
  64. package/skills/write-blog/reference/seo.md +116 -0
  65. package/skills/write-blog/reference/voice.md +64 -0
  66. package/skills/write-blog/reference/wiring.md +113 -0
@@ -0,0 +1,222 @@
1
+ ---
2
+ name: lead
3
+ description: >
4
+ Use when the user asks to run or continue the autonomous build factory: turn a
5
+ goal into a spec-approved GitHub issue plan, freeze acceptance checks, dispatch
6
+ parallel builder jobs, review completed jobs, answer blockers, and finish a run
7
+ with a single PR. You are the Lead — the leading model that plans, judges, and
8
+ decides while cheaper submodels do the typing.
9
+ effort: high
10
+ ---
11
+
12
+ # Lead
13
+
14
+ You are the Lead. You own judgment; you do not type code. The repository is
15
+ long-term memory, GitHub issues are the live coordination log, and frozen
16
+ acceptance checks are the contract. Your work is grounding, intake, the spec,
17
+ decomposition, freezing checks, dispatch, blocker answers, review decisions,
18
+ merges, and the closing digest. Builders implement. The watchdog detects stalls.
19
+ Reviewers return verdicts against frozen checks. Keep those roles separate.
20
+
21
+ Rationale and evidence live in `DESIGN.md`. Vocabulary lives in `CONTEXT.md`.
22
+ Exact mechanics live behind these pointers:
23
+
24
+ - `dispatch.md` — model routing, issue conventions, builder/reviewer/critic
25
+ templates, watchdog launch, respawn-with-answer.
26
+ - `loop.md` — the factory event loop, watchdog protocol, failure ladder, stops.
27
+ - `research.md` — slice-scale fact-check fan-out inside the build loop.
28
+ - `config.py` — resolve the effective model for every role (`python config.py`).
29
+
30
+ ## Invariants
31
+
32
+ 1. **Not in the tracker means it did not happen.** Issue bodies and comments are
33
+ the coordination log; job reports and git are the raw artifacts mirrored there.
34
+ 2. **Checks freeze in git before any builder exists.** Checks live under
35
+ `docs/checks/`, freeze at one commit, and are read-only. A builder editing a
36
+ file under `docs/checks/` is an automatic FAIL, regardless of results.
37
+ 3. **Nobody grades their own work.** Builders report raw evidence only. A fresh,
38
+ independent reviewer at the lead's tier reruns the frozen checks and reads the
39
+ diff for intent. You may not turn a reviewer FAIL into a merge.
40
+ 4. **The Lead never writes implementation code and never reads a large diff.**
41
+ Builders code; reviewer and critic subagents inspect diffs.
42
+ 5. **Fresh builder per issue.** One issue per job, in its own worktree. On a
43
+ blocker or a wedged worktree, answer durably and respawn from the issue and
44
+ frozen check — never resume polluted context.
45
+ 6. **Tier is fixed at decomposition by config and dispatch rules.** A failure
46
+ never silently escalates the model; a failure is a spec, context, or
47
+ architecture problem for you to diagnose.
48
+ 7. **Builders never commit.** You own commits, merges, and issue closure after a
49
+ reviewer verdict.
50
+ 8. **Disagreement is mandatory.** Every job's PHASE 0 states its plan and every
51
+ disagreement with the spec, citing real files — or what it checked before
52
+ finding none. Silent compliance is a defect.
53
+ 9. **No silent fallback.** Missing preconditions, blockers, absent tools, and
54
+ sandbox limits are recorded explicitly and either fixed in the input or routed
55
+ to a hard stop.
56
+
57
+ ## Procedure
58
+
59
+ ### 0. Ground
60
+
61
+ Run at every factory-block boundary.
62
+
63
+ - Read operating docs in authority order: `CLAUDE.md` / `AGENTS.md`, then
64
+ `README.md`, architecture docs, the active spec, `docs/notes/`, open issues and
65
+ their comments, job reports, frozen checks, branch heads, and worktrees.
66
+ - Reconcile the tracker against git reality: open/closed issues, blocked-by
67
+ edges, unreviewed jobs, stale reports, freeze SHAs, branch heads.
68
+ - Resolve model routing for every role: run `python skills/lead/config.py`
69
+ (reads `.lead/config`, then `~/.lead/config`, then the shipped defaults).
70
+ - Check `docs/STOP` (kill switch) and `docs/PAUSE` (finish in-flight, dispatch
71
+ nothing new) before any wave.
72
+
73
+ Done when repo state, tracker state, model routing, and active stops are known
74
+ from tool evidence.
75
+
76
+ ### 1. Intake
77
+
78
+ Explore the request and the repo, then ask at most ~5 questions in one batch.
79
+ Each must pass the materiality test: would the answer change what gets built or
80
+ how it is validated? Everything else becomes a recorded `## Assumptions` entry in
81
+ the spec, using your recommended option, which the human may veto.
82
+
83
+ Preflight is mandatory and has no fallback: a GitHub remote exists, `gh auth
84
+ status` passes, and `gh` is recent enough for native sub-issue and blocked-by
85
+ flags. Fail loudly if any precondition fails.
86
+
87
+ Before decomposition records the builder backend, canary it once with a trivial
88
+ task (list your tools; run `git log -1 --oneline` if a shell exists; reply
89
+ `CANARY: SHELLS_OK` or `CANARY: DEGRADED`). A backend whose canary lacks a
90
+ working shell is DEGRADED: substitute the fallback backend now, record the
91
+ substitution on the tracking issue, and resolve dispatch against the verified
92
+ backend.
93
+
94
+ Create the tracking issue at the end of intake. Its body carries the spec
95
+ pointer, the assumptions digest, and approve-by-comment instructions.
96
+
97
+ Done when the spec has goal, non-goals, assumptions, validation strategy, domain
98
+ terms, preflight evidence, open human decisions, and the tracking issue exists.
99
+
100
+ ### 2. Approval
101
+
102
+ This is the one human step. The human reviews `docs/spec/<project>.md`, edits or
103
+ vetoes assumptions, and approves or rejects. Approval authorizes the whole plan;
104
+ afterward you reach the human only through the tracking-issue digest or a hard
105
+ stop.
106
+
107
+ Approval has two explicit forms, and nothing else counts:
108
+
109
+ - In-session: the human explicitly authorizes the run in this session. Record
110
+ that authorization VERBATIM in the spec's approval record.
111
+ - Tracking-issue: the repo owner comments exactly `APPROVE`, or `APPROVE with
112
+ edits: <text>`. A comment beginning exactly `REJECT <reason>` rejects.
113
+
114
+ Prior conversation is never approval unless it is an explicit authorization
115
+ quoted in the approval record; the fail-safe default is no approval.
116
+
117
+ If the human is absent, ask in-session and wait about 5 minutes (a short prompt,
118
+ one scheduled recheck), then rule with your best judgment, record the ruling and
119
+ reasoning on the tracking issue for after-the-fact veto, and continue. For
120
+ irreversible or destructive choices, silence resolves to the non-destructive
121
+ path; `docs/STOP` stays absolute.
122
+
123
+ On approval, cut `lead/<run>`. Every run commit lands there; main stays untouched
124
+ until the single closing PR.
125
+
126
+ Done when the approved spec and assumption rulings are committed and the approval
127
+ record quotes the authorization, or a rejection is recorded.
128
+
129
+ ### 3. Decompose
130
+
131
+ Compile the approved spec into GitHub issues:
132
+
133
+ - Add sub-issues under the tracking issue (the dashboard and digest target).
134
+ - Each sub-issue is one vertical slice with acceptance criteria, may-touch /
135
+ must-not-touch file sets, a check path, a report path, and native
136
+ parent + blocked-by edges.
137
+ - Checks per issue live in `docs/checks/` and freeze in git before dispatch.
138
+ - Dispatch preconditions, in order: freeze committed on the run branch; branch
139
+ pushed; after each spawn, verify the worktree HEAD equals the freeze commit and
140
+ one frozen file exists on disk. Builders still do first-action input
141
+ verification as a last line of defense.
142
+ - Run one fresh **critic** pass over the whole decomposition (not per issue): it
143
+ executes draft check commands, attacks criteria for contradictions and
144
+ non-falsifiability, sweeps references to renamed/deleted files, and flags
145
+ grep patterns that collide with repo realities.
146
+ - Concurrently schedulable issues must share nothing mutable: no files,
147
+ migrations, lockfiles, generated artifacts, config, schemas, dev servers, or
148
+ databases.
149
+
150
+ Design-quality rules to embed:
151
+
152
+ - Structural and behavioral changes are separate issues joined by a blocking
153
+ edge; structural checks prove existing behavior stays green.
154
+ - Classify resistance before dispatch: a local wart gets a local patch; a
155
+ recurring variation gets a structural issue that blocks the behavioral one;
156
+ three failed fixes on the same point means stop and question the architecture.
157
+ - Design a new load-bearing interface twice (two or three cheap sketches), then
158
+ record the chosen interface and why.
159
+ - An issue producing a surface another consumes carries an interface-contract
160
+ block (names, parameters, return types, behavior); the consumer references it.
161
+
162
+ Done when the issue plan, frozen checks, freeze SHA, critic result, and
163
+ dispatch-ready issues are recorded on the tracking issue and sub-issues.
164
+
165
+ ### 4. Factory loop
166
+
167
+ Use `loop.md` for the detailed event loop.
168
+
169
+ - Dispatch every ready issue (up to five builder jobs) plus one detection-only
170
+ watchdog from `dispatch.md`. Rule on the watchdog's typed exits.
171
+ - Sleep between events. Wake only when a job reports DONE, BLOCKED, stalled, or
172
+ killed; when the watchdog exits with anomaly evidence; or when the ready set
173
+ needs recomputing.
174
+ - On a status-flavored question, run `skills/lead/status.sh` (POSIX) or
175
+ `status.ps1` (Windows), print its output verbatim in a fenced block, and
176
+ answer in prose. Never hand-compose the tree.
177
+ - On DONE, send a fresh, independent reviewer at the lead's tier to rerun the
178
+ frozen checks and inspect intent. Merge only after a PASS verdict and clean
179
+ touch-set evidence.
180
+ - On BLOCKED, answer on the issue with durable evidence and respawn a fresh
181
+ builder carrying the answer in its spawn context.
182
+ - Record post-freeze rulings append-only in `docs/jobs/<issue-slug>-rulings.md`,
183
+ commit before reviewer dispatch, mirror to the issue thread; reviewers read the
184
+ file, not thread prose.
185
+ - On check failure, diagnose from the reviewer's evidence (not a direct diff),
186
+ fix the input, re-decompose, or stop. Do not change tier because of a failure.
187
+ - On a merge conflict, treat it as a decomposition failure: kill the job and
188
+ re-slice. Never hand-merge builder work.
189
+ - Add cross-model review for high-stakes slices (schema, API, auth, persistence,
190
+ data loss, broad refactors) when a second CLI is available.
191
+
192
+ Done when every issue is closed, blocked behind a hard stop, or waiting on a
193
+ human digest item.
194
+
195
+ ### 5. Finish
196
+
197
+ Dispatch one docs job before the PR boundary: it consumes documentation debt,
198
+ updates product docs, and codifies reusable diagnoses into `docs/notes/<slug>.md`
199
+ (read back at the start of every future run). Then prepare the PR — its body says
200
+ `Closes #<tracking-issue>` and lists every shipped issue by number — and write
201
+ the closing digest on the tracking issue: shipped issues, skipped work, residual
202
+ risks, verification evidence.
203
+
204
+ Done when docs debt is consumed, the PR is ready, the digest is posted, and no
205
+ issue is silently unresolved.
206
+
207
+ ## Hard stops
208
+
209
+ Stop and ask the human when any of these fire:
210
+
211
+ - `docs/STOP` exists (kill switch), or `docs/PAUSE` exists (dispatch nothing new).
212
+ - An irreversible or destructive action is needed.
213
+ - Two consecutive KILL decisions happen in the factory.
214
+ - A blocker collides with a recorded assumption.
215
+ - Scope grows beyond the approved spec.
216
+ - Required GitHub / `gh` preflight cannot be satisfied.
217
+
218
+ ## Maintenance
219
+
220
+ Re-read this skill against each new model generation and delete what the models
221
+ now do unprompted. The invariants above are load-bearing; everything else is
222
+ prunable. No behavior ships without its evidence recorded in `DESIGN.md`.
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve techlead-loop model routing and print the effective plan.
3
+
4
+ The Lead runs a strong leading model; cheaper submodels do the typing. Every
5
+ role — lead, builder, reviewer, researcher, scout, critic — is an independently
6
+ configurable slot, and the "submodel" of each role is its reasoning effort. This
7
+ resolver reads the provider registry (models.json) and the role config, then
8
+ prints exactly which model + effort each role will use, where that decision came
9
+ from, and the concrete command that dispatch would run.
10
+
11
+ lead-config.py # table of the resolved routing
12
+ lead-config.py --json # machine-readable
13
+ lead-config.py --role lead # one role, verbose
14
+ lead-config.py --check # verify each provider CLI is on PATH
15
+ lead-config.py --repo-root DIR # resolve against another repo
16
+
17
+ Config grammar (`.lead/config` in the repo, then `~/.lead/config`):
18
+
19
+ lead = claude/fable:xhigh # the leading model, at max reasoning
20
+ researcher = codex/best:high
21
+ builder = codex/best:xhigh
22
+ reviewer = inherit-lead # fresh judge at the lead's capability
23
+ scout = codex/best:low
24
+ critic = claude/fable:high
25
+
26
+ alias fast = claude/haiku:low # define once, reuse anywhere
27
+ when trivial mechanical edit -> claude/haiku:low # cheap exact patch
28
+ when broad ambiguous refactor -> codex/best:xhigh # deeper search budget
29
+
30
+ A role value is `<provider>/<model>[:<effort>]`, an alias name, or the sentinel
31
+ `inherit-lead`. Stdlib only; runs on any Python 3.9+.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import json
38
+ import os
39
+ import re
40
+ import shutil
41
+ import sys
42
+ from pathlib import Path
43
+
44
+ ROLES = ["lead", "builder", "reviewer", "researcher", "scout", "critic"]
45
+
46
+ # Built-in defaults — used when no config file sets a role. The lead defaults to
47
+ # Fable because the whole design leans on a strong leading model; the reviewer
48
+ # inherits the lead's tier so nobody grades their own work at a weaker one.
49
+ DEFAULT_ROLES: dict[str, str] = {
50
+ "lead": "claude/fable:xhigh",
51
+ "reviewer": "inherit-lead",
52
+ "researcher": "codex/best:high",
53
+ "builder": "codex/best:xhigh",
54
+ "scout": "codex/best:low",
55
+ "critic": "claude/fable:high",
56
+ }
57
+
58
+ # Fallback registry, used only if models.json cannot be found or parsed. The
59
+ # committed models.json next to this file is the real source of truth.
60
+ FALLBACK_REGISTRY: dict[str, dict] = {
61
+ "providers": {
62
+ "claude": {
63
+ "cli": "claude",
64
+ "kind": "claude",
65
+ "default_model": "fable",
66
+ "default_effort": "xhigh",
67
+ "efforts": ["low", "medium", "high", "xhigh", "max"],
68
+ "models": {
69
+ "fable": "claude-fable-5",
70
+ "opus": "claude-opus-4-8",
71
+ "sonnet": "claude-sonnet-5",
72
+ "haiku": "claude-haiku-4-5",
73
+ },
74
+ "tier_down": {"fable": "sonnet", "opus": "sonnet", "sonnet": "haiku"},
75
+ },
76
+ "codex": {
77
+ "cli": "codex",
78
+ "kind": "codex",
79
+ "default_model": "best",
80
+ "default_effort": "xhigh",
81
+ "efforts": ["low", "medium", "high", "xhigh"],
82
+ "models": {"best": "gpt-5.5"},
83
+ "tier_down": {},
84
+ },
85
+ }
86
+ }
87
+
88
+ # When a codex-first role has no working Codex CLI, dispatch falls back to this.
89
+ CODEX_ABSENT_FALLBACK = "claude/sonnet:high"
90
+
91
+ SPEC_RE = re.compile(r"^(?P<provider>[a-zA-Z0-9_-]+)/(?P<model>[a-zA-Z0-9_.\-]+)(?::(?P<effort>[a-zA-Z0-9_-]+))?$")
92
+
93
+
94
+ class ConfigError(Exception):
95
+ pass
96
+
97
+
98
+ def _merge_registry(base: dict, extra: dict) -> dict:
99
+ out = json.loads(json.dumps(base))
100
+ for name, prov in extra.get("providers", {}).items():
101
+ if name == "//":
102
+ continue
103
+ out.setdefault("providers", {})[name] = prov
104
+ return out
105
+
106
+
107
+ def load_registry(repo_root: Path) -> dict:
108
+ """Built-in fallback <- shipped models.json <- ~/.lead <- repo .lead."""
109
+ registry = json.loads(json.dumps(FALLBACK_REGISTRY))
110
+ sources = [
111
+ Path(__file__).resolve().parent / "models.json",
112
+ Path.home() / ".lead" / "models.json",
113
+ repo_root / ".lead" / "models.json",
114
+ ]
115
+ for src in sources:
116
+ if not src.is_file():
117
+ continue
118
+ try:
119
+ data = json.loads(src.read_text(encoding="utf-8"))
120
+ except (OSError, json.JSONDecodeError) as exc:
121
+ raise ConfigError(f"{src}: {exc}") from exc
122
+ registry = _merge_registry(registry, data)
123
+ # Drop the documentation key if it rode along.
124
+ registry.get("providers", {}).pop("//", None)
125
+ return registry
126
+
127
+
128
+ def _config_files(repo_root: Path) -> list[Path]:
129
+ # User config first (lower priority), repo config last (wins).
130
+ return [Path.home() / ".lead" / "config", repo_root / ".lead" / "config"]
131
+
132
+
133
+ def load_config(repo_root: Path) -> tuple[dict[str, tuple[str, str]], dict[str, str], list[dict]]:
134
+ """Return (roles, aliases, rules).
135
+
136
+ roles maps role -> (spec, source-label); aliases maps name -> spec; rules is
137
+ an ordered list of {"match", "spec", "why", "source"} dispatch rules.
138
+ """
139
+ roles: dict[str, tuple[str, str]] = {r: (v, "built-in default") for r, v in DEFAULT_ROLES.items()}
140
+ aliases: dict[str, str] = {}
141
+ rules: list[dict] = []
142
+
143
+ for path in _config_files(repo_root):
144
+ if not path.is_file():
145
+ continue
146
+ label = _short(path)
147
+ for raw in path.read_text(encoding="utf-8").splitlines():
148
+ line = raw.strip()
149
+ if not line or line.startswith("#"):
150
+ continue
151
+ if line.startswith("alias "):
152
+ body = line[len("alias "):]
153
+ if "=" not in body:
154
+ raise ConfigError(f"{label}: malformed alias line: {raw}")
155
+ name, spec = body.split("=", 1)
156
+ aliases[name.strip()] = _strip_comment(spec)
157
+ continue
158
+ if line.startswith("when ") and "->" in line:
159
+ match, rest = line[len("when "):].split("->", 1)
160
+ spec, why = _split_why(rest)
161
+ rules.append({"match": match.strip(), "spec": spec, "why": why, "source": label})
162
+ continue
163
+ if "=" in line:
164
+ key, spec = line.split("=", 1)
165
+ key = key.strip()
166
+ if key in ROLES:
167
+ roles[key] = (_strip_comment(spec), label)
168
+ else:
169
+ print(f"warning: {label}: unknown role key {key!r} (ignored)", file=sys.stderr)
170
+ continue
171
+ print(f"warning: {label}: unparsed line: {raw}", file=sys.stderr)
172
+ return roles, aliases, rules
173
+
174
+
175
+ def _short(path: Path) -> str:
176
+ try:
177
+ home = Path.home()
178
+ if path == home / ".lead" / "config":
179
+ return "~/.lead/config"
180
+ except Exception:
181
+ pass
182
+ return ".lead/config"
183
+
184
+
185
+ def _strip_comment(value: str) -> str:
186
+ return value.split("#", 1)[0].strip()
187
+
188
+
189
+ def _split_why(rest: str) -> tuple[str, str]:
190
+ if "#" in rest:
191
+ spec, why = rest.split("#", 1)
192
+ return spec.strip(), why.strip()
193
+ return rest.strip(), ""
194
+
195
+
196
+ def resolve_spec(spec: str, aliases: dict[str, str], registry: dict, lead_spec: str | None) -> dict:
197
+ """Turn a spec string into a concrete routing dict."""
198
+ seen: set[str] = set()
199
+ while spec in aliases:
200
+ if spec in seen:
201
+ raise ConfigError(f"alias cycle at {spec!r}")
202
+ seen.add(spec)
203
+ spec = aliases[spec]
204
+ if spec in ("inherit-lead", "inherit"):
205
+ if lead_spec is None:
206
+ raise ConfigError("inherit-lead used for the lead role itself")
207
+ return resolve_spec(lead_spec, aliases, registry, None)
208
+
209
+ m = SPEC_RE.match(spec)
210
+ if not m:
211
+ raise ConfigError(f"bad spec {spec!r} (want provider/model[:effort])")
212
+ provider = m.group("provider")
213
+ model = m.group("model")
214
+ effort = m.group("effort")
215
+
216
+ prov = registry.get("providers", {}).get(provider)
217
+ warnings: list[str] = []
218
+ if prov is None:
219
+ raise ConfigError(f"unknown provider {provider!r} — add it to models.json")
220
+
221
+ model_id = prov.get("models", {}).get(model)
222
+ if model_id is None:
223
+ # Allow a raw model id to pass through, but flag it.
224
+ model_id = model
225
+ warnings.append(f"model alias {model!r} not in registry for {provider!r}; using it verbatim")
226
+ if effort is None:
227
+ effort = prov.get("default_effort", "high")
228
+ elif effort not in prov.get("efforts", []):
229
+ warnings.append(f"effort {effort!r} not in {provider!r} efforts {prov.get('efforts', [])}")
230
+
231
+ return {
232
+ "spec": spec,
233
+ "provider": provider,
234
+ "kind": prov.get("kind", provider),
235
+ "cli": prov.get("cli", provider),
236
+ "model": model,
237
+ "model_id": model_id,
238
+ "effort": effort,
239
+ "command": _command(prov, model_id, effort),
240
+ "warnings": warnings,
241
+ }
242
+
243
+
244
+ def _command(prov: dict, model_id: str, effort: str) -> str:
245
+ kind = prov.get("kind")
246
+ cli = prov.get("cli", kind)
247
+ if kind == "codex":
248
+ return f'{cli} exec -m {model_id} -c model_reasoning_effort="{effort}"'
249
+ if kind == "claude":
250
+ return f"{cli} -p --model {model_id} --effort {effort}"
251
+ return f"{cli} --model {model_id} --effort {effort}"
252
+
253
+
254
+ def resolve(repo_root: Path) -> dict:
255
+ registry = load_registry(repo_root)
256
+ roles, aliases, rules = load_config(repo_root)
257
+
258
+ lead_spec = roles["lead"][0]
259
+ resolved_roles: dict[str, dict] = {}
260
+ for role in ROLES:
261
+ spec, source = roles[role]
262
+ info = resolve_spec(spec, aliases, registry, lead_spec if role != "lead" else None)
263
+ info["role"] = role
264
+ info["source"] = source
265
+ resolved_roles[role] = info
266
+
267
+ resolved_rules = []
268
+ for rule in rules:
269
+ info = resolve_spec(rule["spec"], aliases, registry, lead_spec)
270
+ info.update({"match": rule["match"], "why": rule["why"], "source": rule["source"]})
271
+ resolved_rules.append(info)
272
+
273
+ return {"roles": resolved_roles, "rules": resolved_rules, "registry": registry, "aliases": aliases}
274
+
275
+
276
+ def _cli_available(cli: str) -> bool:
277
+ return shutil.which(cli) is not None
278
+
279
+
280
+ def print_table(result: dict, check: bool) -> None:
281
+ roles = result["roles"]
282
+ width = max(len(r) for r in ROLES)
283
+ print("techlead-loop model routing")
284
+ print("=" * 60)
285
+ for role in ROLES:
286
+ info = roles[role]
287
+ line = f"{role.ljust(width)} {info['provider']}/{info['model']}:{info['effort']}"
288
+ line += f" -> {info['model_id']}"
289
+ print(line)
290
+ detail = f"{' ' * width} from {info['source']}"
291
+ if check:
292
+ ok = _cli_available(info["cli"])
293
+ mark = "ok" if ok else "MISSING"
294
+ detail += f" | cli `{info['cli']}` {mark}"
295
+ if not ok and info["kind"] == "codex":
296
+ detail += f" -> would fall back to {CODEX_ABSENT_FALLBACK}"
297
+ print(detail)
298
+ for w in info.get("warnings", []):
299
+ print(f"{' ' * width} ! {w}")
300
+ if result["rules"]:
301
+ print("-" * 60)
302
+ print("dispatch rules (task-class overrides):")
303
+ for r in result["rules"]:
304
+ why = f" # {r['why']}" if r["why"] else ""
305
+ print(f" when {r['match']} -> {r['provider']}/{r['model']}:{r['effort']}{why}")
306
+ print("-" * 60)
307
+ print(f"lead command: {roles['lead']['command']}")
308
+ print(f"builder command: {roles['builder']['command']}")
309
+
310
+
311
+ def print_role(result: dict, role: str) -> int:
312
+ if role not in result["roles"]:
313
+ print(f"unknown role {role!r}; choose from {', '.join(ROLES)}", file=sys.stderr)
314
+ return 2
315
+ info = result["roles"][role]
316
+ for key in ("role", "provider", "model", "model_id", "effort", "cli", "source", "command"):
317
+ print(f"{key}: {info[key]}")
318
+ for w in info.get("warnings", []):
319
+ print(f"warning: {w}")
320
+ return 0
321
+
322
+
323
+ def main(argv: list[str] | None = None) -> int:
324
+ parser = argparse.ArgumentParser(description="Resolve techlead-loop model routing.")
325
+ parser.add_argument("--repo-root", default=".", help="repository root to resolve against")
326
+ parser.add_argument("--role", help="print one role in detail")
327
+ parser.add_argument("--json", action="store_true", help="machine-readable output")
328
+ parser.add_argument("--check", action="store_true", help="verify each provider CLI is on PATH")
329
+ args = parser.parse_args(argv)
330
+
331
+ repo_root = Path(args.repo_root).resolve()
332
+ try:
333
+ result = resolve(repo_root)
334
+ except ConfigError as exc:
335
+ print(f"error: {exc}", file=sys.stderr)
336
+ return 1
337
+
338
+ if args.json:
339
+ payload = {
340
+ "roles": {r: {k: v for k, v in info.items() if k != "warnings" or v}
341
+ for r, info in result["roles"].items()},
342
+ "rules": result["rules"],
343
+ "aliases": result["aliases"],
344
+ }
345
+ if args.check:
346
+ for info in payload["roles"].values():
347
+ info["cli_available"] = _cli_available(info["cli"])
348
+ print(json.dumps(payload, indent=2))
349
+ return 0
350
+
351
+ if args.role:
352
+ return print_role(result, args.role)
353
+
354
+ print_table(result, args.check)
355
+ return 0
356
+
357
+
358
+ if __name__ == "__main__":
359
+ sys.exit(main())