git-worklog 1.6.0__py3-none-any.whl
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.
- git_worklog/__init__.py +58 -0
- git_worklog/__main__.py +10 -0
- git_worklog/analysis/__init__.py +48 -0
- git_worklog/analysis/commits.py +118 -0
- git_worklog/analysis/coverage.py +149 -0
- git_worklog/analysis/history.py +339 -0
- git_worklog/analysis/manifest.py +432 -0
- git_worklog/analysis/reconcile.py +211 -0
- git_worklog/analysis/refs.py +178 -0
- git_worklog/analysis/results.py +871 -0
- git_worklog/analysis/worktree.py +114 -0
- git_worklog/cli/__init__.py +414 -0
- git_worklog/cli/analyze.py +340 -0
- git_worklog/cli/apply.py +61 -0
- git_worklog/cli/coverage.py +62 -0
- git_worklog/cli/doctor.py +348 -0
- git_worklog/cli/migrate.py +43 -0
- git_worklog/cli/models.py +112 -0
- git_worklog/cli/preview.py +124 -0
- git_worklog/cli/refs.py +55 -0
- git_worklog/cli/reindex.py +72 -0
- git_worklog/cli/report.py +227 -0
- git_worklog/cli/validate.py +252 -0
- git_worklog/cli/version.py +47 -0
- git_worklog/cli/view.py +218 -0
- git_worklog/config.py +79 -0
- git_worklog/data/provider_models.json +34 -0
- git_worklog/dates.py +237 -0
- git_worklog/language.py +295 -0
- git_worklog/markers.py +765 -0
- git_worklog/mdhtml.py +266 -0
- git_worklog/migrate.py +305 -0
- git_worklog/paths.py +85 -0
- git_worklog/preview.py +776 -0
- git_worklog/providers.py +337 -0
- git_worklog/viewer.py +1198 -0
- git_worklog/viewserver.py +120 -0
- git_worklog/writer.py +420 -0
- git_worklog-1.6.0.dist-info/METADATA +352 -0
- git_worklog-1.6.0.dist-info/RECORD +44 -0
- git_worklog-1.6.0.dist-info/WHEEL +5 -0
- git_worklog-1.6.0.dist-info/entry_points.txt +2 -0
- git_worklog-1.6.0.dist-info/licenses/LICENSE +21 -0
- git_worklog-1.6.0.dist-info/top_level.txt +1 -0
git_worklog/providers.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Resolve the subagent provider/model for the host the skill runs under.
|
|
2
|
+
|
|
3
|
+
The Git Worklog orchestrator runs under exactly one host — Claude Code
|
|
4
|
+
(``anthropic``), Codex (``openai``), or Gemini (``google``) — and every Day
|
|
5
|
+
Subagent and Code Analysis Subagent for a run executes on that host's single
|
|
6
|
+
model. This is the deterministic, single-source resolver: it reads the model
|
|
7
|
+
table, selects the entry for the given host, and applies the override
|
|
8
|
+
precedence.
|
|
9
|
+
|
|
10
|
+
**The table is the user's file.** Models are renamed and retired faster than a
|
|
11
|
+
tool should have to be released, so the table that is read is
|
|
12
|
+
``~/.git-worklog/provider_models.json`` (:func:`git_worklog.paths.models_path`),
|
|
13
|
+
not the one inside the package. The packaged
|
|
14
|
+
``git_worklog/data/provider_models.json`` is only the seed: the first time a
|
|
15
|
+
model is resolved it is copied out, and from then on the copy is read and
|
|
16
|
+
never rewritten -- not by a run, not by an upgrade. A newer release that ships
|
|
17
|
+
newer defaults therefore changes nothing for someone who already has the file;
|
|
18
|
+
``git-worklog doctor`` is what tells them the two differ.
|
|
19
|
+
|
|
20
|
+
Override precedence for the model id (highest first):
|
|
21
|
+
|
|
22
|
+
1. ``model`` (explicit runtime/CLI model id)
|
|
23
|
+
2. environment variable ``GIT_WORKLOG_<HOST>_MODEL`` (or the deprecated
|
|
24
|
+
``REPO_WORKLOG_<HOST>_MODEL``)
|
|
25
|
+
3. the provider default in the user's ``provider_models.json``
|
|
26
|
+
|
|
27
|
+
The host is NEVER guessed: a missing or unknown host is a configuration error,
|
|
28
|
+
never a silent pick of the first provider. A model that cannot be resolved (empty
|
|
29
|
+
id) raises with the selectable candidate list — the skill must never silently
|
|
30
|
+
fall back to a pricier or arbitrary host default.
|
|
31
|
+
|
|
32
|
+
Escalation is opt-in only: automatic escalation is disabled and the flag is used
|
|
33
|
+
solely after the user explicitly approves an escalation re-run.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import tempfile
|
|
41
|
+
|
|
42
|
+
from git_worklog import paths
|
|
43
|
+
|
|
44
|
+
VALID_HOSTS = ("anthropic", "openai", "google")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ProviderError(ValueError):
|
|
48
|
+
"""A provider/model that cannot be resolved, carrying the wire code.
|
|
49
|
+
|
|
50
|
+
Mirrors :class:`git_worklog.dates.DateError` and
|
|
51
|
+
:class:`git_worklog.analysis.AnalysisError`: the callers are thin shells that
|
|
52
|
+
owe the user one JSON object with a stable ``code``.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, code: str, message: str, **extra):
|
|
56
|
+
self.code = code
|
|
57
|
+
self.message = message
|
|
58
|
+
self.extra = extra
|
|
59
|
+
super().__init__(message)
|
|
60
|
+
|
|
61
|
+
def as_error(self) -> dict:
|
|
62
|
+
"""The error dict as it goes on the wire."""
|
|
63
|
+
return {"code": self.code, "message": self.message, **self.extra}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def default_config_path() -> str:
|
|
67
|
+
"""``provider_models.json`` as shipped inside the package.
|
|
68
|
+
|
|
69
|
+
It lives in the package rather than in a sibling ``config/`` directory
|
|
70
|
+
because only ``git_worklog*`` is packaged: from a sibling, an installed CLI
|
|
71
|
+
could not find it and would have no model to resolve. Here, the skill layout
|
|
72
|
+
and the wheel reach the same one file -- which is the point of calling it the
|
|
73
|
+
single source of truth.
|
|
74
|
+
"""
|
|
75
|
+
return os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
76
|
+
"data", "provider_models.json")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
USER_COMMENT = (
|
|
80
|
+
"Subagent models per host, read by git-worklog on every run. This file is "
|
|
81
|
+
"yours: edit model_id / escalation_model_id (and display_name) when a "
|
|
82
|
+
"provider renames or retires a model. git-worklog copied it from its "
|
|
83
|
+
"shipped defaults once and never rewrites it on its own; `git-worklog models "
|
|
84
|
+
"--host <host> --model <id> --apply` is the same edit as a command. "
|
|
85
|
+
"`git-worklog doctor` lists "
|
|
86
|
+
"where it differs from the defaults of the installed version; delete the "
|
|
87
|
+
"file to start again from those.")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def write_user_config(config: dict) -> str:
|
|
91
|
+
"""Write ``config`` as the user's model table, whole or not at all."""
|
|
92
|
+
path = paths.models_path()
|
|
93
|
+
paths.ensure_dir(os.path.dirname(path))
|
|
94
|
+
fd, staged = tempfile.mkstemp(prefix=".rw-models-", dir=os.path.dirname(path))
|
|
95
|
+
try:
|
|
96
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
97
|
+
json.dump(config, fh, ensure_ascii=False, indent=2)
|
|
98
|
+
fh.write("\n")
|
|
99
|
+
os.replace(staged, path) # two first runs at once: either copy is right
|
|
100
|
+
except BaseException:
|
|
101
|
+
os.unlink(staged)
|
|
102
|
+
raise
|
|
103
|
+
return path
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def seed() -> dict:
|
|
107
|
+
"""What a new user table holds: the shipped defaults, addressed to the user."""
|
|
108
|
+
return dict(load_config(default_config_path()), _comment=USER_COMMENT)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def ensure_user_config() -> "tuple[str, dict | None]":
|
|
112
|
+
"""The path of the model table to read, seeding the user's copy if absent.
|
|
113
|
+
|
|
114
|
+
Returns ``(path, notice)``. Seeding is reported, once, as
|
|
115
|
+
``USER_MODELS_CREATED``: the shipped defaults are as old as the release they
|
|
116
|
+
came in, and the moment the file appears is the moment to ask the user
|
|
117
|
+
whether the host has newer models. The notice carries that instruction
|
|
118
|
+
itself, because the agent reading this output is the one who has to ask.
|
|
119
|
+
|
|
120
|
+
When the copy cannot be written -- a read-only home, a sandbox -- the
|
|
121
|
+
shipped table is read instead and ``USER_MODELS_NOT_WRITTEN`` says so: a run
|
|
122
|
+
still resolves a model, and the user learns why their file is not there to
|
|
123
|
+
edit.
|
|
124
|
+
"""
|
|
125
|
+
path = paths.models_path()
|
|
126
|
+
if os.path.exists(path):
|
|
127
|
+
return path, None
|
|
128
|
+
try:
|
|
129
|
+
write_user_config(seed())
|
|
130
|
+
except OSError as exc:
|
|
131
|
+
return default_config_path(), {
|
|
132
|
+
"code": "USER_MODELS_NOT_WRITTEN",
|
|
133
|
+
"message": f"{path} could not be created ({exc}); the shipped model "
|
|
134
|
+
"defaults were used for this run.",
|
|
135
|
+
"target": path,
|
|
136
|
+
}
|
|
137
|
+
return path, {
|
|
138
|
+
"code": "USER_MODELS_CREATED",
|
|
139
|
+
"message": f"{path} did not exist and was created from the defaults shipped "
|
|
140
|
+
"with this version, which may be older than the models the host "
|
|
141
|
+
"offers now. Before going on, ask the user whether to update it "
|
|
142
|
+
"to the host's current models; if they agree, look up the "
|
|
143
|
+
"host's current model ids and write them with `git-worklog "
|
|
144
|
+
"models --host <host> --model <id> --escalation-model <id> "
|
|
145
|
+
"--apply`. It is the user's file and is never rewritten for them.",
|
|
146
|
+
"target": path,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def differences(user: dict, shipped: dict) -> "list[dict]":
|
|
151
|
+
"""Where the user's providers differ from the shipped ones, field by field."""
|
|
152
|
+
out = []
|
|
153
|
+
ours, theirs = user.get("providers") or {}, shipped.get("providers") or {}
|
|
154
|
+
for host in sorted(set(ours) | set(theirs)):
|
|
155
|
+
a, b = ours.get(host) or {}, theirs.get(host) or {}
|
|
156
|
+
for field in sorted(set(a) | set(b)):
|
|
157
|
+
if a.get(field) != b.get(field):
|
|
158
|
+
out.append({"provider": host, "field": field,
|
|
159
|
+
"yours": a.get(field), "shipped": b.get(field)})
|
|
160
|
+
return out
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def env_var(host: str) -> str:
|
|
164
|
+
return f"GIT_WORKLOG_{host.upper()}_MODEL"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _legacy_env_var(host: str) -> str:
|
|
168
|
+
"""The pre-v0.7 name. Shipped in v0.3.0-v0.4.0, so it is still honoured."""
|
|
169
|
+
return f"REPO_WORKLOG_{host.upper()}_MODEL"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _resolve_env(host: str) -> "tuple[str | None, str | None]":
|
|
173
|
+
"""Read the model override from the environment.
|
|
174
|
+
|
|
175
|
+
Returns ``(value, deprecated_var_used)``. The current name wins; the legacy
|
|
176
|
+
one is honoured because it was publicly released and someone's shell profile
|
|
177
|
+
still exports it. Silently ignoring it would swap their model without a word
|
|
178
|
+
-- the one thing this module exists to never do.
|
|
179
|
+
"""
|
|
180
|
+
val = os.environ.get(env_var(host)) or None
|
|
181
|
+
if val:
|
|
182
|
+
return val, None
|
|
183
|
+
legacy = os.environ.get(_legacy_env_var(host)) or None
|
|
184
|
+
if legacy:
|
|
185
|
+
return legacy, _legacy_env_var(host)
|
|
186
|
+
return None, None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _candidates(providers: dict) -> list:
|
|
190
|
+
"""Every configured provider's human label + id, for halt-and-ask lists."""
|
|
191
|
+
out = []
|
|
192
|
+
for key in VALID_HOSTS:
|
|
193
|
+
entry = providers.get(key)
|
|
194
|
+
if not entry:
|
|
195
|
+
continue
|
|
196
|
+
out.append({
|
|
197
|
+
"provider": key,
|
|
198
|
+
"display_name": entry.get("display_name"),
|
|
199
|
+
"model_id": entry.get("model_id"),
|
|
200
|
+
})
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def load_config(path: "str | None" = None) -> dict:
|
|
205
|
+
"""Read a model table. Raises ProviderError if it is unusable.
|
|
206
|
+
|
|
207
|
+
A table that cannot be read is an error and never a reason to read another
|
|
208
|
+
one: quietly falling back to the shipped defaults would swap the model the
|
|
209
|
+
user chose for one they did not.
|
|
210
|
+
"""
|
|
211
|
+
path = path or default_config_path()
|
|
212
|
+
try:
|
|
213
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
214
|
+
config = json.load(fh)
|
|
215
|
+
except FileNotFoundError:
|
|
216
|
+
raise ProviderError("CONFIG_NOT_FOUND",
|
|
217
|
+
f"Provider model config not found: {path}", target=path)
|
|
218
|
+
except (ValueError, OSError) as exc:
|
|
219
|
+
raise ProviderError("CONFIG_ERROR",
|
|
220
|
+
f"Could not read provider model config {path}: {exc}",
|
|
221
|
+
target=path)
|
|
222
|
+
if not isinstance(config, dict) or not isinstance(config.get("providers"), dict):
|
|
223
|
+
raise ProviderError("CONFIG_ERROR",
|
|
224
|
+
f"{path} has no \"providers\" object.", target=path)
|
|
225
|
+
return config
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def resolve(host: "str | None", model: "str | None" = None,
|
|
229
|
+
reasoning_effort: "str | None" = None, escalate: bool = False,
|
|
230
|
+
config_path: "str | None" = None) -> dict:
|
|
231
|
+
"""Resolve ``host`` to its model. Raises ProviderError on any config fault.
|
|
232
|
+
|
|
233
|
+
With no ``config_path`` the user's table is read, and seeded first if it
|
|
234
|
+
does not exist yet. An explicit path is read as given and seeds nothing.
|
|
235
|
+
"""
|
|
236
|
+
seed_warning = None
|
|
237
|
+
if config_path is None:
|
|
238
|
+
config_path, seed_warning = ensure_user_config()
|
|
239
|
+
config = load_config(config_path)
|
|
240
|
+
providers = config.get("providers") or {}
|
|
241
|
+
|
|
242
|
+
# The host is never guessed. Missing / unknown host is a config error.
|
|
243
|
+
if not host:
|
|
244
|
+
raise ProviderError(
|
|
245
|
+
"UNKNOWN_HOST",
|
|
246
|
+
"No host was provided. The skill must detect its host (Claude Code -> "
|
|
247
|
+
"anthropic, Codex -> openai, Gemini -> google) and pass --host; it must "
|
|
248
|
+
"not guess or default to the first provider.",
|
|
249
|
+
valid_hosts=list(VALID_HOSTS))
|
|
250
|
+
if host not in VALID_HOSTS or host not in providers:
|
|
251
|
+
raise ProviderError(
|
|
252
|
+
"UNKNOWN_HOST",
|
|
253
|
+
f"Unknown or unconfigured host '{host}'.",
|
|
254
|
+
requested_host=host, valid_hosts=list(VALID_HOSTS))
|
|
255
|
+
|
|
256
|
+
entry = providers[host]
|
|
257
|
+
escalating = bool(escalate)
|
|
258
|
+
|
|
259
|
+
# Pick the base id for this mode, then apply the override precedence on top.
|
|
260
|
+
if escalating:
|
|
261
|
+
base_id = entry.get("escalation_model_id")
|
|
262
|
+
if not base_id:
|
|
263
|
+
raise ProviderError(
|
|
264
|
+
"NO_ESCALATION_MODEL",
|
|
265
|
+
f"Provider '{host}' has no escalation_model_id configured; "
|
|
266
|
+
"escalation is unavailable for this host.",
|
|
267
|
+
provider=host)
|
|
268
|
+
base_effort = entry.get("escalation_reasoning_effort")
|
|
269
|
+
else:
|
|
270
|
+
base_id = entry.get("model_id")
|
|
271
|
+
base_effort = entry.get("reasoning_effort")
|
|
272
|
+
|
|
273
|
+
env_val, deprecated_var = _resolve_env(host)
|
|
274
|
+
cli_val = model or None
|
|
275
|
+
if cli_val:
|
|
276
|
+
model_id, source = cli_val, "cli"
|
|
277
|
+
elif env_val:
|
|
278
|
+
model_id, source = env_val, "env"
|
|
279
|
+
else:
|
|
280
|
+
model_id, source = base_id, "config"
|
|
281
|
+
|
|
282
|
+
if not model_id or not str(model_id).strip():
|
|
283
|
+
# Never silently fall back to a host default or a pricier model.
|
|
284
|
+
raise ProviderError(
|
|
285
|
+
"MODEL_UNAVAILABLE",
|
|
286
|
+
f"No model id could be resolved for provider '{host}'. No fallback "
|
|
287
|
+
"model was selected automatically. Configure an available model id "
|
|
288
|
+
f"(config default, {env_var(host)}, or --model) and re-run the dry-run.",
|
|
289
|
+
provider=host,
|
|
290
|
+
requested_model_id=model_id or "",
|
|
291
|
+
candidates=_candidates(providers))
|
|
292
|
+
|
|
293
|
+
# reasoning_effort: CLI override wins; otherwise the mode's configured value.
|
|
294
|
+
# Absent (e.g. anthropic / google) -> the key is omitted entirely, never "".
|
|
295
|
+
effort = reasoning_effort or base_effort
|
|
296
|
+
resolved_model = {
|
|
297
|
+
"display_name": entry.get("display_name") if not escalating else str(model_id),
|
|
298
|
+
"model_id": str(model_id),
|
|
299
|
+
}
|
|
300
|
+
if effort:
|
|
301
|
+
resolved_model["reasoning_effort"] = effort
|
|
302
|
+
|
|
303
|
+
escalation_id = entry.get("escalation_model_id")
|
|
304
|
+
result = {
|
|
305
|
+
"ok": True,
|
|
306
|
+
"provider": host,
|
|
307
|
+
"escalated": escalating,
|
|
308
|
+
"model": resolved_model,
|
|
309
|
+
"model_id_source": source,
|
|
310
|
+
"config_path": config_path,
|
|
311
|
+
"escalation": {
|
|
312
|
+
"available": bool(escalation_id),
|
|
313
|
+
"model_id": escalation_id,
|
|
314
|
+
},
|
|
315
|
+
"escalation_policy": config.get("escalation_policy",
|
|
316
|
+
{"automatic": False, "suggest_when": []}),
|
|
317
|
+
"model_unavailable_policy": config.get("model_unavailable_policy",
|
|
318
|
+
"halt-and-ask"),
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
# An honoured-but-renamed variable is reported, never silently obeyed: the
|
|
322
|
+
# user is picking a model, and they should know which knob actually did it.
|
|
323
|
+
if deprecated_var and source == "env":
|
|
324
|
+
result["warnings"] = [{
|
|
325
|
+
"code": "DEPRECATED_ENV_VAR",
|
|
326
|
+
"message": f"{deprecated_var} is deprecated and will be removed in "
|
|
327
|
+
f"v2.0; rename it to {env_var(host)}. It was honoured "
|
|
328
|
+
"for this run.",
|
|
329
|
+
"deprecated": deprecated_var,
|
|
330
|
+
"replacement": env_var(host),
|
|
331
|
+
}]
|
|
332
|
+
if seed_warning:
|
|
333
|
+
result.setdefault("warnings", []).append(seed_warning)
|
|
334
|
+
esc_effort = entry.get("escalation_reasoning_effort")
|
|
335
|
+
if esc_effort:
|
|
336
|
+
result["escalation"]["reasoning_effort"] = esc_effort
|
|
337
|
+
return result
|