@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.
- package/.claude/agents/lead-builder.md +55 -0
- package/.claude/agents/lead-reviewer.md +39 -0
- package/README.md +276 -0
- package/assets/readme-hero.png +0 -0
- package/assets/skill-cards/async-learning-teacher.svg +7 -0
- package/assets/skill-cards/code-standards.svg +7 -0
- package/assets/skill-cards/lead-research.svg +7 -0
- package/assets/skill-cards/lead.svg +7 -0
- package/assets/skill-cards/orwell-writing.svg +7 -0
- package/assets/skill-cards/session-profiler.svg +7 -0
- package/assets/skill-cards/validate-market.svg +7 -0
- package/assets/skill-cards/write-blog.svg +7 -0
- package/bin/builder-essential-skills.js +134 -0
- package/install.ps1 +58 -0
- package/install.sh +63 -0
- package/package.json +24 -0
- package/skills/async-learning-teacher/README.md +50 -0
- package/skills/async-learning-teacher/SKILL.md +192 -0
- package/skills/async-learning-teacher/agents/openai.yaml +4 -0
- package/skills/code-standards/README.md +27 -0
- package/skills/code-standards/SKILL.md +51 -0
- package/skills/lead/README.md +29 -0
- package/skills/lead/SKILL.md +222 -0
- package/skills/lead/config.py +359 -0
- package/skills/lead/dispatch.md +342 -0
- package/skills/lead/loop.md +103 -0
- package/skills/lead/models.json +34 -0
- package/skills/lead/research.md +76 -0
- package/skills/lead/status.ps1 +114 -0
- package/skills/lead/status.sh +110 -0
- package/skills/lead/watchdog.ps1 +90 -0
- package/skills/lead/watchdog.sh +88 -0
- package/skills/lead-research/README.md +27 -0
- package/skills/lead-research/SKILL.md +157 -0
- package/skills/lead-research/tactics.md +150 -0
- package/skills/orwell-writing/README.md +27 -0
- package/skills/orwell-writing/SKILL.md +44 -0
- package/skills/orwell-writing/agents/openai.yaml +4 -0
- package/skills/session-profiler/README.md +25 -0
- package/skills/session-profiler/SKILL.md +103 -0
- package/skills/session-profiler/agents/openai.yaml +4 -0
- package/skills/session-profiler/scripts/requirements.txt +2 -0
- package/skills/session-profiler/scripts/session_profiler/__init__.py +5 -0
- package/skills/session-profiler/scripts/session_profiler/analyses.py +246 -0
- package/skills/session-profiler/scripts/session_profiler/cli.py +77 -0
- package/skills/session-profiler/scripts/session_profiler/dataset.py +369 -0
- package/skills/session-profiler/scripts/session_profiler/discover.py +191 -0
- package/skills/session-profiler/scripts/session_profiler/toc.py +74 -0
- package/skills/session-profiler/scripts/session_profiler/trace.py +146 -0
- package/skills/session-profiler/scripts/sp +20 -0
- package/skills/session-profiler/scripts/tests/codex_home/sessions/2026/01/01/rollout-child.jsonl +7 -0
- package/skills/session-profiler/scripts/tests/codex_home/sessions/2026/01/01/rollout-root.jsonl +10 -0
- package/skills/session-profiler/scripts/tests/fixture/main/subagents/agent-abc.jsonl +2 -0
- package/skills/session-profiler/scripts/tests/fixture/main/subagents/agent-abc.meta.json +1 -0
- package/skills/session-profiler/scripts/tests/fixture/main.jsonl +7 -0
- package/skills/session-profiler/scripts/tests/test_fixture.py +58 -0
- package/skills/validate-market/README.md +27 -0
- package/skills/validate-market/SKILL.md +202 -0
- package/skills/write-blog/README.md +27 -0
- package/skills/write-blog/SKILL.md +142 -0
- package/skills/write-blog/check.sh +52 -0
- package/skills/write-blog/reference/angles.md +51 -0
- package/skills/write-blog/reference/craft.md +94 -0
- package/skills/write-blog/reference/seo.md +116 -0
- package/skills/write-blog/reference/voice.md +64 -0
- 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())
|