roadmodel 0.2.95__tar.gz → 0.2.97__tar.gz
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.
- {roadmodel-0.2.95 → roadmodel-0.2.97}/PKG-INFO +1 -1
- {roadmodel-0.2.95/roadmodel/data → roadmodel-0.2.97/docs/claude-commands}/roadmap-refresh.md +9 -1
- {roadmodel-0.2.95/roadmodel/data → roadmodel-0.2.97/docs/claude-commands}/roadmap-step.md +7 -2
- {roadmodel-0.2.95/roadmodel/data → roadmodel-0.2.97/docs/claude-commands}/roadmodel-upgrade.md +19 -1
- {roadmodel-0.2.95/roadmodel/data → roadmodel-0.2.97/docs/templates}/phase-roadmap-template.md +12 -5
- {roadmodel-0.2.95 → roadmodel-0.2.97}/pyproject.toml +1 -1
- {roadmodel-0.2.95/docs/templates → roadmodel-0.2.97/roadmodel/data}/phase-roadmap-template.md +12 -5
- {roadmodel-0.2.95/docs/claude-commands → roadmodel-0.2.97/roadmodel/data}/roadmap-refresh.md +9 -1
- {roadmodel-0.2.95/docs/claude-commands → roadmodel-0.2.97/roadmodel/data}/roadmap-step.md +7 -2
- {roadmodel-0.2.95/docs/claude-commands → roadmodel-0.2.97/roadmodel/data}/roadmodel-upgrade.md +19 -1
- {roadmodel-0.2.95 → roadmodel-0.2.97}/scripts/update_projects.py +291 -1
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/__init__.py +1 -1
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/cli.py +174 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/ladder_layout.py +156 -3
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/scoring.py +78 -81
- {roadmodel-0.2.95 → roadmodel-0.2.97}/.github/README.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/.gitignore +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/LICENSE +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/NOTICE +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/README.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/benchmarks.json +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/catalog.json +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/claude-commands/roadmap-phase.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/claude-commands/roadmap-project.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/context-windows.json +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/effort-tokens.json +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/model-selector.txt +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/model-tier-cost-scale.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/settings-display.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/templates/planning-kit-how-to-use.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/templates/project-roadmap-template.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/templates/prompt-phase-roadmap.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/templates/prompt-project-roadmap.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/user-context.example.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/hatch_build.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/infra/README.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/infra/gcp-killswitch/README.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/infra/supabase/README.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/benchmarks.json +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/catalog.json +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/context-windows.json +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/effort-tokens.json +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/model-selector.txt +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/model-tier-cost-scale.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/planning-kit-how-to-use.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/project-roadmap-template.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/prompt-phase-roadmap.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/prompt-project-roadmap.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/roadmap-phase.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/roadmap-project.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/settings-display.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/user-context.example.md +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/scripts/agent_context.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/scripts/agent_rules.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/scripts/updater_lock.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/scripts/upgrade_fleet.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/scripts/worktree_memory.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/__main__.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/config.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/cost.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/errors.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/mcp_server.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/private_files.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/providers/__init__.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/providers/_safety.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/providers/anthropic.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/providers/google.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/providers/openai.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/providers/openai_compatible.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/providers/registry.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/recommend.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/series.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/usage.py +0 -0
- {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/user_context.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: roadmodel
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.97
|
|
4
4
|
Summary: BYO-key CLI that recommends the right AI model, platform, and settings for a prompt.
|
|
5
5
|
Project-URL: Homepage, https://roadmodel.ai
|
|
6
6
|
Project-URL: Repository, https://github.com/nathanramoscfa/roadmodel
|
{roadmodel-0.2.95/roadmodel/data → roadmodel-0.2.97/docs/claude-commands}/roadmap-refresh.md
RENAMED
|
@@ -221,7 +221,15 @@ complexity, novel or not; its Model rationale names them), then:
|
|
|
221
221
|
not exist: a Complete step's ladder is the record of what ran, an `In
|
|
222
222
|
progress` step's is what an agent is running, and a written phase's
|
|
223
223
|
records what wrote its roadmap, so those keep their layout. That is a
|
|
224
|
-
layout fix, not a new pick, and takes no `Settings updated` line.
|
|
224
|
+
layout fix, not a new pick, and takes no `Settings updated` line. Then
|
|
225
|
+
run `roadmodel rerank <every roadmap file>` (roadmodel 0.2.97 on): it
|
|
226
|
+
gives each of those ladder tables the ranks this release computes, so a
|
|
227
|
+
change to how roadmodel ranks reaches every step not yet run. It reads
|
|
228
|
+
the classification from the ladder's note (`Ranked for …`), else from the
|
|
229
|
+
only classification whose ladder has the same Picks. It adds a `Settings
|
|
230
|
+
updated` line where a rank changed. It reports any ladder it cannot
|
|
231
|
+
place: classify that step from its Model rationale and re-run with
|
|
232
|
+
`--hint FILE:SECTION=category/complexity[/novel]`. A
|
|
225
233
|
ladder written before roadmodel 0.2.93 has Model / Backup / Platform /
|
|
226
234
|
Effort (/ AA Index) / Thinking rows and no Runner-up: on a step that has
|
|
227
235
|
not shipped, or a phase not yet written, replace it with the ladder
|
|
@@ -115,7 +115,11 @@ the dials and make sure it is set.
|
|
|
115
115
|
Price rows. Level is the bare level of that platform's own dial (`High`
|
|
116
116
|
is Intelligence High on Codex, Effort High on Claude Code and
|
|
117
117
|
Antigravity). The ✓ in a column's header marks the one its **Runs:**
|
|
118
|
-
line names.
|
|
118
|
+
line names. From roadmodel 0.2.97 the three ranks of a column come from
|
|
119
|
+
three makers (a tight pool is a maker's); where too few are funded, a
|
|
120
|
+
missing rank is the Pick one level lower, its Level marked ↓, and the
|
|
121
|
+
note line says which classification the ladder was ranked for. A 0.2.94
|
|
122
|
+
ladder is three lists instead (`- Pick:`,
|
|
119
123
|
`- Runner-up:`, `- Backup:` under each column's bold heading), and a
|
|
120
124
|
0.2.93 one a table with rows `1 · Pick`, `2 · Runner-up` and
|
|
121
125
|
`3 · Backup`, each followed by Platform, Effort, Thinking and AA Index
|
|
@@ -129,7 +133,8 @@ the dials and make sure it is set.
|
|
|
129
133
|
subscription that pays for that column's Pick's platform:
|
|
130
134
|
- `headroom`: run that column's Pick.
|
|
131
135
|
- `tight`: run the first of that column's Runner-up and Backup whose
|
|
132
|
-
pool reads `headroom
|
|
136
|
+
pool reads `headroom` or that is marked ↓ (the Pick one level lower,
|
|
137
|
+
which uses less of the tight pool), else the Cost column's Pick.
|
|
133
138
|
- `exhausted`: run the first of that column's Runner-up and Backup
|
|
134
139
|
whose pool reads `headroom`, else its Backup.
|
|
135
140
|
|
{roadmodel-0.2.95/roadmodel/data → roadmodel-0.2.97/docs/claude-commands}/roadmodel-upgrade.md
RENAMED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Upgrade roadmodel and shared agent context across registered projects and configured machines (usage: /roadmodel-upgrade [project dirs to register…] [--sync-only | --check] [--local-only] [--skip-roadmap-refresh] [--refresh-roadmaps] [--install-schedule HH:MM])
|
|
2
|
+
description: Upgrade roadmodel and shared agent context across registered projects and configured machines (usage: /roadmodel-upgrade [project dirs to register…] [--sync-only | --check] [--local-only] [--skip-roadmap-refresh] [--refresh-roadmaps] [--install-schedule HH:MM] [--install-sync-schedule [MINUTES]])
|
|
3
3
|
---
|
|
4
4
|
Upgrade roadmodel in every registered project's own environment, refresh its
|
|
5
5
|
`planning/` kit and agent commands, and maintain live shared rules, full
|
|
@@ -172,6 +172,24 @@ and registries are local; installing one schedule does not install another
|
|
|
172
172
|
on a peer. When the operator has requested automatic upkeep, install and
|
|
173
173
|
verify it within that authorization.
|
|
174
174
|
|
|
175
|
+
**Checkout sync (roadmodel 0.2.96 on).** A roadmap change merged on GitHub
|
|
176
|
+
reaches a project's checkout only when it pulls, so the editor kept showing
|
|
177
|
+
the old version. `--install-sync-schedule [MINUTES]` (default 15) registers
|
|
178
|
+
`--sync-checkouts` on its own schedule: launchd, Task Scheduler while the user
|
|
179
|
+
is logged on, or cron. Every full run also syncs, after its roadmap refresh.
|
|
180
|
+
For each registered checkout that sits on its default branch, it fetches
|
|
181
|
+
origin and fast-forwards (`git merge --ff-only`). It never merges, rebases,
|
|
182
|
+
stashes, resets or cleans. It leaves alone, and reports in the log, any
|
|
183
|
+
checkout that:
|
|
184
|
+
- is on a feature branch (an agent's step, which gets main when it rebases);
|
|
185
|
+
- has local commits;
|
|
186
|
+
- has a merge or rebase in progress;
|
|
187
|
+
- has uncommitted changes to a file the update changes.
|
|
188
|
+
|
|
189
|
+
Untracked files are never touched. `--uninstall-sync-schedule` removes it.
|
|
190
|
+
The operator asked for this on 2026-10-25: install it on every machine, peers
|
|
191
|
+
included (over SSH: `<python> <launcher> --install-sync-schedule`).
|
|
192
|
+
|
|
175
193
|
## 6. Refresh every project's roadmaps
|
|
176
194
|
|
|
177
195
|
`--refresh-roadmaps` runs `/roadmap-refresh` in every registered project on
|
{roadmodel-0.2.95/roadmodel/data → roadmodel-0.2.97/docs/templates}/phase-roadmap-template.md
RENAMED
|
@@ -774,11 +774,18 @@ that owns it.}}
|
|
|
774
774
|
platform has the toggle, and every AA Index and Price
|
|
775
775
|
already assumes it. A note line under the table explains
|
|
776
776
|
✓ and Price, and a `**Conversation:**` line closes the
|
|
777
|
-
ladder.
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
the
|
|
781
|
-
|
|
777
|
+
ladder. Each rank comes from its own maker (roadmodel
|
|
778
|
+
0.2.97 on), because a tight usage pool is a maker's: the
|
|
779
|
+
Runner-up is the column's recommendation with the Pick's
|
|
780
|
+
maker left out, and the Backup leaves out both makers
|
|
781
|
+
above it. Where the user-context funds too few makers, a
|
|
782
|
+
missing rank is the Pick's own model one level lower,
|
|
783
|
+
its Level marked ↓ (it uses less of a tight pool), and
|
|
784
|
+
`none` below the dial's lowest level. The note line
|
|
785
|
+
names the classification the ladder was ranked for. The
|
|
786
|
+
ranks follow the column's own rule, not the AA Index:
|
|
787
|
+
Cost ranks each maker's cheapest adequate option, so its
|
|
788
|
+
AA Index may rise down the column. Every model is the latest
|
|
782
789
|
version of its series, never an older one (GPT-6 Sol
|
|
783
790
|
where GPT-6.1 Sol runs, Opus 5 where Opus 5.5 does).
|
|
784
791
|
Set the Conversation line to **Continue** only when the
|
{roadmodel-0.2.95/docs/templates → roadmodel-0.2.97/roadmodel/data}/phase-roadmap-template.md
RENAMED
|
@@ -774,11 +774,18 @@ that owns it.}}
|
|
|
774
774
|
platform has the toggle, and every AA Index and Price
|
|
775
775
|
already assumes it. A note line under the table explains
|
|
776
776
|
✓ and Price, and a `**Conversation:**` line closes the
|
|
777
|
-
ladder.
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
the
|
|
781
|
-
|
|
777
|
+
ladder. Each rank comes from its own maker (roadmodel
|
|
778
|
+
0.2.97 on), because a tight usage pool is a maker's: the
|
|
779
|
+
Runner-up is the column's recommendation with the Pick's
|
|
780
|
+
maker left out, and the Backup leaves out both makers
|
|
781
|
+
above it. Where the user-context funds too few makers, a
|
|
782
|
+
missing rank is the Pick's own model one level lower,
|
|
783
|
+
its Level marked ↓ (it uses less of a tight pool), and
|
|
784
|
+
`none` below the dial's lowest level. The note line
|
|
785
|
+
names the classification the ladder was ranked for. The
|
|
786
|
+
ranks follow the column's own rule, not the AA Index:
|
|
787
|
+
Cost ranks each maker's cheapest adequate option, so its
|
|
788
|
+
AA Index may rise down the column. Every model is the latest
|
|
782
789
|
version of its series, never an older one (GPT-6 Sol
|
|
783
790
|
where GPT-6.1 Sol runs, Opus 5 where Opus 5.5 does).
|
|
784
791
|
Set the Conversation line to **Continue** only when the
|
{roadmodel-0.2.95/docs/claude-commands → roadmodel-0.2.97/roadmodel/data}/roadmap-refresh.md
RENAMED
|
@@ -221,7 +221,15 @@ complexity, novel or not; its Model rationale names them), then:
|
|
|
221
221
|
not exist: a Complete step's ladder is the record of what ran, an `In
|
|
222
222
|
progress` step's is what an agent is running, and a written phase's
|
|
223
223
|
records what wrote its roadmap, so those keep their layout. That is a
|
|
224
|
-
layout fix, not a new pick, and takes no `Settings updated` line.
|
|
224
|
+
layout fix, not a new pick, and takes no `Settings updated` line. Then
|
|
225
|
+
run `roadmodel rerank <every roadmap file>` (roadmodel 0.2.97 on): it
|
|
226
|
+
gives each of those ladder tables the ranks this release computes, so a
|
|
227
|
+
change to how roadmodel ranks reaches every step not yet run. It reads
|
|
228
|
+
the classification from the ladder's note (`Ranked for …`), else from the
|
|
229
|
+
only classification whose ladder has the same Picks. It adds a `Settings
|
|
230
|
+
updated` line where a rank changed. It reports any ladder it cannot
|
|
231
|
+
place: classify that step from its Model rationale and re-run with
|
|
232
|
+
`--hint FILE:SECTION=category/complexity[/novel]`. A
|
|
225
233
|
ladder written before roadmodel 0.2.93 has Model / Backup / Platform /
|
|
226
234
|
Effort (/ AA Index) / Thinking rows and no Runner-up: on a step that has
|
|
227
235
|
not shipped, or a phase not yet written, replace it with the ladder
|
|
@@ -115,7 +115,11 @@ the dials and make sure it is set.
|
|
|
115
115
|
Price rows. Level is the bare level of that platform's own dial (`High`
|
|
116
116
|
is Intelligence High on Codex, Effort High on Claude Code and
|
|
117
117
|
Antigravity). The ✓ in a column's header marks the one its **Runs:**
|
|
118
|
-
line names.
|
|
118
|
+
line names. From roadmodel 0.2.97 the three ranks of a column come from
|
|
119
|
+
three makers (a tight pool is a maker's); where too few are funded, a
|
|
120
|
+
missing rank is the Pick one level lower, its Level marked ↓, and the
|
|
121
|
+
note line says which classification the ladder was ranked for. A 0.2.94
|
|
122
|
+
ladder is three lists instead (`- Pick:`,
|
|
119
123
|
`- Runner-up:`, `- Backup:` under each column's bold heading), and a
|
|
120
124
|
0.2.93 one a table with rows `1 · Pick`, `2 · Runner-up` and
|
|
121
125
|
`3 · Backup`, each followed by Platform, Effort, Thinking and AA Index
|
|
@@ -129,7 +133,8 @@ the dials and make sure it is set.
|
|
|
129
133
|
subscription that pays for that column's Pick's platform:
|
|
130
134
|
- `headroom`: run that column's Pick.
|
|
131
135
|
- `tight`: run the first of that column's Runner-up and Backup whose
|
|
132
|
-
pool reads `headroom
|
|
136
|
+
pool reads `headroom` or that is marked ↓ (the Pick one level lower,
|
|
137
|
+
which uses less of the tight pool), else the Cost column's Pick.
|
|
133
138
|
- `exhausted`: run the first of that column's Runner-up and Backup
|
|
134
139
|
whose pool reads `headroom`, else its Backup.
|
|
135
140
|
|
{roadmodel-0.2.95/docs/claude-commands → roadmodel-0.2.97/roadmodel/data}/roadmodel-upgrade.md
RENAMED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Upgrade roadmodel and shared agent context across registered projects and configured machines (usage: /roadmodel-upgrade [project dirs to register…] [--sync-only | --check] [--local-only] [--skip-roadmap-refresh] [--refresh-roadmaps] [--install-schedule HH:MM])
|
|
2
|
+
description: Upgrade roadmodel and shared agent context across registered projects and configured machines (usage: /roadmodel-upgrade [project dirs to register…] [--sync-only | --check] [--local-only] [--skip-roadmap-refresh] [--refresh-roadmaps] [--install-schedule HH:MM] [--install-sync-schedule [MINUTES]])
|
|
3
3
|
---
|
|
4
4
|
Upgrade roadmodel in every registered project's own environment, refresh its
|
|
5
5
|
`planning/` kit and agent commands, and maintain live shared rules, full
|
|
@@ -172,6 +172,24 @@ and registries are local; installing one schedule does not install another
|
|
|
172
172
|
on a peer. When the operator has requested automatic upkeep, install and
|
|
173
173
|
verify it within that authorization.
|
|
174
174
|
|
|
175
|
+
**Checkout sync (roadmodel 0.2.96 on).** A roadmap change merged on GitHub
|
|
176
|
+
reaches a project's checkout only when it pulls, so the editor kept showing
|
|
177
|
+
the old version. `--install-sync-schedule [MINUTES]` (default 15) registers
|
|
178
|
+
`--sync-checkouts` on its own schedule: launchd, Task Scheduler while the user
|
|
179
|
+
is logged on, or cron. Every full run also syncs, after its roadmap refresh.
|
|
180
|
+
For each registered checkout that sits on its default branch, it fetches
|
|
181
|
+
origin and fast-forwards (`git merge --ff-only`). It never merges, rebases,
|
|
182
|
+
stashes, resets or cleans. It leaves alone, and reports in the log, any
|
|
183
|
+
checkout that:
|
|
184
|
+
- is on a feature branch (an agent's step, which gets main when it rebases);
|
|
185
|
+
- has local commits;
|
|
186
|
+
- has a merge or rebase in progress;
|
|
187
|
+
- has uncommitted changes to a file the update changes.
|
|
188
|
+
|
|
189
|
+
Untracked files are never touched. `--uninstall-sync-schedule` removes it.
|
|
190
|
+
The operator asked for this on 2026-10-25: install it on every machine, peers
|
|
191
|
+
included (over SSH: `<python> <launcher> --install-sync-schedule`).
|
|
192
|
+
|
|
175
193
|
## 6. Refresh every project's roadmaps
|
|
176
194
|
|
|
177
195
|
`--refresh-roadmaps` runs `/roadmap-refresh` in every registered project on
|
|
@@ -2019,6 +2019,11 @@ def refresh_roadmaps(
|
|
|
2019
2019
|
SCHEDULE_LABEL = "com.roadmodel.update-projects" # launchd label / cron marker
|
|
2020
2020
|
TASK_NAME = "roadmodel-update-projects" # Windows Task Scheduler name
|
|
2021
2021
|
LOG_FILE = CONFIG_DIR / "update.log"
|
|
2022
|
+
# The checkout sync's own schedule: fast-forward every registered checkout on
|
|
2023
|
+
# its default branch every SYNC_MINUTES (sync_checkout, below).
|
|
2024
|
+
SYNC_LABEL = "com.roadmodel.sync-checkouts"
|
|
2025
|
+
SYNC_TASK = "roadmodel-sync-checkouts"
|
|
2026
|
+
SYNC_MINUTES = 15
|
|
2022
2027
|
LOG_MAX_BYTES = 1_000_000
|
|
2023
2028
|
|
|
2024
2029
|
|
|
@@ -2258,6 +2263,243 @@ def uninstall_schedule(dry_run: bool = False) -> str:
|
|
|
2258
2263
|
return "removed crontab entry"
|
|
2259
2264
|
|
|
2260
2265
|
|
|
2266
|
+
def _sync_argv(python: Path, script: Path) -> list[str]:
|
|
2267
|
+
return [str(python), "-I", "-X", "utf8", str(script), "--sync-checkouts", "--log"]
|
|
2268
|
+
|
|
2269
|
+
|
|
2270
|
+
def sync_launchd_plist(python: Path, script: Path, minutes: int) -> str:
|
|
2271
|
+
"""The checkout sync as a launchd agent that starts every ``minutes``."""
|
|
2272
|
+
args = "".join(
|
|
2273
|
+
f" <string>{escape(a)}</string>\n" for a in _sync_argv(python, script)
|
|
2274
|
+
)
|
|
2275
|
+
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
2276
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2277
|
+
<plist version="1.0">
|
|
2278
|
+
<dict>
|
|
2279
|
+
<key>Label</key><string>{SYNC_LABEL}</string>
|
|
2280
|
+
<key>ProgramArguments</key>
|
|
2281
|
+
<array>
|
|
2282
|
+
{args} </array>
|
|
2283
|
+
<key>StartInterval</key><integer>{minutes * 60}</integer>
|
|
2284
|
+
<key>RunAtLoad</key><true/>
|
|
2285
|
+
<key>StandardOutPath</key><string>/dev/null</string>
|
|
2286
|
+
<key>StandardErrorPath</key><string>/dev/null</string>
|
|
2287
|
+
<key>EnvironmentVariables</key>
|
|
2288
|
+
<dict><key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string></dict>
|
|
2289
|
+
</dict>
|
|
2290
|
+
</plist>
|
|
2291
|
+
"""
|
|
2292
|
+
|
|
2293
|
+
|
|
2294
|
+
def sync_schtasks_argv(python: Path, script: Path, minutes: int) -> list[str]:
|
|
2295
|
+
"""The checkout sync as a Task Scheduler task that repeats every
|
|
2296
|
+
``minutes`` while the user is logged on (git reads their credentials)."""
|
|
2297
|
+
return [
|
|
2298
|
+
"schtasks", "/Create", "/F",
|
|
2299
|
+
"/TN", SYNC_TASK,
|
|
2300
|
+
"/SC", "MINUTE",
|
|
2301
|
+
"/MO", str(minutes),
|
|
2302
|
+
"/TR", subprocess.list2cmdline(_sync_argv(python, script)),
|
|
2303
|
+
] # fmt: skip
|
|
2304
|
+
|
|
2305
|
+
|
|
2306
|
+
def sync_cron_line(python: Path, script: Path, minutes: int) -> str:
|
|
2307
|
+
command = shlex.join(_sync_argv(python, script)).replace("%", r"\%")
|
|
2308
|
+
return f"*/{minutes} * * * * {command} # {SYNC_LABEL}"
|
|
2309
|
+
|
|
2310
|
+
|
|
2311
|
+
def install_sync_schedule(minutes: int, dry_run: bool = False) -> str:
|
|
2312
|
+
"""Register the checkout sync to run every ``minutes`` for this user."""
|
|
2313
|
+
if not 1 <= minutes <= 1440:
|
|
2314
|
+
raise SystemExit("--install-sync-schedule: minutes must be 1–1440")
|
|
2315
|
+
python = _launcher_python()
|
|
2316
|
+
script = _install_launcher(dry_run)
|
|
2317
|
+
stamp = f"every {minutes} min via {python} {script} --sync-checkouts (log: {LOG_FILE})"
|
|
2318
|
+
if sys.platform == "darwin":
|
|
2319
|
+
plist = Path.home() / "Library" / "LaunchAgents" / f"{SYNC_LABEL}.plist"
|
|
2320
|
+
if dry_run:
|
|
2321
|
+
return f"launchd agent {plist} — {stamp}"
|
|
2322
|
+
plist.parent.mkdir(parents=True, exist_ok=True)
|
|
2323
|
+
domain = f"gui/{os.getuid()}"
|
|
2324
|
+
_sys(["launchctl", "bootout", f"{domain}/{SYNC_LABEL}"]) # ok if not loaded
|
|
2325
|
+
_replace_bytes(plist, sync_launchd_plist(python, script, minutes).encode("utf-8"))
|
|
2326
|
+
cp = _sys(["launchctl", "bootstrap", domain, str(plist)])
|
|
2327
|
+
if cp.returncode != 0:
|
|
2328
|
+
raise SystemExit(f"launchctl bootstrap failed: {cp.stderr.strip() or cp.stdout.strip()}")
|
|
2329
|
+
return f"launchd agent {plist} — {stamp}"
|
|
2330
|
+
if WINDOWS:
|
|
2331
|
+
if dry_run:
|
|
2332
|
+
return f"Task Scheduler task {SYNC_TASK} — {stamp}"
|
|
2333
|
+
cp = _sys(sync_schtasks_argv(python, script, minutes))
|
|
2334
|
+
if cp.returncode != 0:
|
|
2335
|
+
raise SystemExit(f"schtasks failed: {cp.stderr.strip() or cp.stdout.strip()}")
|
|
2336
|
+
return f"Task Scheduler task {SYNC_TASK} — {stamp}"
|
|
2337
|
+
line = sync_cron_line(python, script, minutes)
|
|
2338
|
+
if dry_run:
|
|
2339
|
+
return f"crontab entry — {stamp}\n {line}"
|
|
2340
|
+
current = _sys(["crontab", "-l"])
|
|
2341
|
+
kept = [
|
|
2342
|
+
ln
|
|
2343
|
+
for ln in (current.stdout if current.returncode == 0 else "").splitlines()
|
|
2344
|
+
if SYNC_LABEL not in ln
|
|
2345
|
+
]
|
|
2346
|
+
cp = _sys(["crontab", "-"], stdin="\n".join(kept + [line]) + "\n")
|
|
2347
|
+
if cp.returncode != 0:
|
|
2348
|
+
raise SystemExit(f"crontab failed: {cp.stderr.strip()}")
|
|
2349
|
+
return f"crontab entry — {stamp}"
|
|
2350
|
+
|
|
2351
|
+
|
|
2352
|
+
def uninstall_sync_schedule(dry_run: bool = False) -> str:
|
|
2353
|
+
if sys.platform == "darwin":
|
|
2354
|
+
plist = Path.home() / "Library" / "LaunchAgents" / f"{SYNC_LABEL}.plist"
|
|
2355
|
+
if not dry_run:
|
|
2356
|
+
_sys(["launchctl", "bootout", f"gui/{os.getuid()}/{SYNC_LABEL}"])
|
|
2357
|
+
plist.unlink(missing_ok=True)
|
|
2358
|
+
return f"removed launchd agent {plist}"
|
|
2359
|
+
if WINDOWS:
|
|
2360
|
+
if not dry_run:
|
|
2361
|
+
_sys(["schtasks", "/Delete", "/F", "/TN", SYNC_TASK])
|
|
2362
|
+
return f"removed Task Scheduler task {SYNC_TASK}"
|
|
2363
|
+
if not dry_run:
|
|
2364
|
+
current = _sys(["crontab", "-l"])
|
|
2365
|
+
kept = [
|
|
2366
|
+
ln
|
|
2367
|
+
for ln in (current.stdout if current.returncode == 0 else "").splitlines()
|
|
2368
|
+
if SYNC_LABEL not in ln
|
|
2369
|
+
]
|
|
2370
|
+
_sys(["crontab", "-"], stdin="\n".join(kept) + "\n")
|
|
2371
|
+
return "removed crontab entry"
|
|
2372
|
+
|
|
2373
|
+
|
|
2374
|
+
# --------------------------------------------------------------------------
|
|
2375
|
+
# Checkout sync
|
|
2376
|
+
# --------------------------------------------------------------------------
|
|
2377
|
+
# The operator reads roadmaps in each project's own checkout. A roadmap change
|
|
2378
|
+
# merged on GitHub (a fleet `roadmodel relayout`, a refresh, a step run on the
|
|
2379
|
+
# other machine) reached that checkout only after a manual `git pull`, so the
|
|
2380
|
+
# editor kept showing the old version (2026-10-25). --sync-checkouts runs on
|
|
2381
|
+
# its own schedule (every SYNC_MINUTES) and at the end of every full run, and
|
|
2382
|
+
# only ever fast-forwards where nothing can be lost:
|
|
2383
|
+
# - the checkout must sit on its default branch; a feature branch, an agent's
|
|
2384
|
+
# step, is left alone and gets main when it rebases;
|
|
2385
|
+
# - `git merge --ff-only` only: never a merge commit, rebase, stash, reset or
|
|
2386
|
+
# clean, and untracked files are never touched;
|
|
2387
|
+
# - local commits, a merge/rebase/cherry-pick in progress, or uncommitted
|
|
2388
|
+
# changes to a file the update changes leave the checkout as it is.
|
|
2389
|
+
|
|
2390
|
+
SYNC_GIT_TIMEOUT = 120
|
|
2391
|
+
_SYNC_BUSY = ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply")
|
|
2392
|
+
|
|
2393
|
+
|
|
2394
|
+
@dataclass(frozen=True)
|
|
2395
|
+
class SyncOutcome:
|
|
2396
|
+
"""What one checkout's sync did: `updated`, `current`, `skipped` (left
|
|
2397
|
+
alone on purpose) or `failed` (git could not do what was asked)."""
|
|
2398
|
+
|
|
2399
|
+
path: Path
|
|
2400
|
+
state: str
|
|
2401
|
+
detail: str
|
|
2402
|
+
|
|
2403
|
+
def line(self) -> str:
|
|
2404
|
+
return f"{self.path.name}: {self.state} — {self.detail}"
|
|
2405
|
+
|
|
2406
|
+
|
|
2407
|
+
def _checkout_git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
2408
|
+
return subprocess.run( # noqa: S603 — fixed git argv, paths from the registry
|
|
2409
|
+
["git", "-C", str(repo), *args], # noqa: S607 — git resolved on PATH, as elsewhere
|
|
2410
|
+
capture_output=True,
|
|
2411
|
+
text=True,
|
|
2412
|
+
timeout=SYNC_GIT_TIMEOUT,
|
|
2413
|
+
)
|
|
2414
|
+
|
|
2415
|
+
|
|
2416
|
+
def _first_line(cp: subprocess.CompletedProcess[str]) -> str:
|
|
2417
|
+
text = (cp.stderr or cp.stdout or "").strip()
|
|
2418
|
+
return text.splitlines()[0] if text else f"exit {cp.returncode}"
|
|
2419
|
+
|
|
2420
|
+
|
|
2421
|
+
def default_branch(repo: Path) -> Optional[str]:
|
|
2422
|
+
"""The remote's default branch as this clone knows it (`origin/HEAD`),
|
|
2423
|
+
else whichever of main and master origin has; None when neither."""
|
|
2424
|
+
cp = _checkout_git(repo, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
|
|
2425
|
+
name = cp.stdout.strip()
|
|
2426
|
+
if cp.returncode == 0 and name.startswith("origin/"):
|
|
2427
|
+
return name[len("origin/") :]
|
|
2428
|
+
for branch in ("main", "master"):
|
|
2429
|
+
ref = f"refs/remotes/origin/{branch}"
|
|
2430
|
+
if _checkout_git(repo, "rev-parse", "--verify", "--quiet", ref).returncode == 0:
|
|
2431
|
+
return branch
|
|
2432
|
+
return None
|
|
2433
|
+
|
|
2434
|
+
|
|
2435
|
+
def _checkout_busy(repo: Path) -> bool:
|
|
2436
|
+
cp = _checkout_git(repo, "rev-parse", "--git-dir")
|
|
2437
|
+
if cp.returncode != 0:
|
|
2438
|
+
return False
|
|
2439
|
+
git_dir = Path(cp.stdout.strip())
|
|
2440
|
+
if not git_dir.is_absolute():
|
|
2441
|
+
git_dir = repo / git_dir
|
|
2442
|
+
markers = (*_SYNC_BUSY, "index.lock")
|
|
2443
|
+
return any((git_dir / marker).exists() for marker in markers)
|
|
2444
|
+
|
|
2445
|
+
|
|
2446
|
+
def sync_checkout(repo: Path, *, dry_run: bool = False) -> SyncOutcome:
|
|
2447
|
+
"""Fast-forward ``repo`` to ``origin/<default branch>`` when it is on that
|
|
2448
|
+
branch and nothing of its own would be lost; otherwise leave it and say
|
|
2449
|
+
why."""
|
|
2450
|
+
repo = Path(repo)
|
|
2451
|
+
if not (repo / ".git").exists():
|
|
2452
|
+
return SyncOutcome(repo, "skipped", "not a git checkout")
|
|
2453
|
+
head = _checkout_git(repo, "symbolic-ref", "--quiet", "--short", "HEAD")
|
|
2454
|
+
if head.returncode != 0:
|
|
2455
|
+
return SyncOutcome(repo, "skipped", "detached HEAD")
|
|
2456
|
+
branch = head.stdout.strip()
|
|
2457
|
+
if _checkout_busy(repo):
|
|
2458
|
+
return SyncOutcome(repo, "skipped", "a merge, rebase or cherry-pick is in progress")
|
|
2459
|
+
default = default_branch(repo)
|
|
2460
|
+
if default is None:
|
|
2461
|
+
return SyncOutcome(repo, "skipped", "origin has no main or master")
|
|
2462
|
+
if branch != default:
|
|
2463
|
+
return SyncOutcome(repo, "skipped", f"on {branch}; it gets {default} when it rebases")
|
|
2464
|
+
fetched = _checkout_git(repo, "fetch", "--quiet", "origin", default)
|
|
2465
|
+
if fetched.returncode != 0:
|
|
2466
|
+
return SyncOutcome(repo, "failed", f"fetch failed: {_first_line(fetched)}")
|
|
2467
|
+
counts = _checkout_git(repo, "rev-list", "--left-right", "--count", f"HEAD...origin/{default}")
|
|
2468
|
+
if counts.returncode != 0:
|
|
2469
|
+
return SyncOutcome(repo, "failed", f"cannot compare with origin: {_first_line(counts)}")
|
|
2470
|
+
ahead, behind = (int(n) for n in counts.stdout.split())
|
|
2471
|
+
if behind == 0:
|
|
2472
|
+
return SyncOutcome(repo, "current", f"{default} is up to date")
|
|
2473
|
+
if ahead:
|
|
2474
|
+
return SyncOutcome(
|
|
2475
|
+
repo, "skipped", f"{ahead} local commit(s) on {default} not on origin; not touched"
|
|
2476
|
+
)
|
|
2477
|
+
if dry_run:
|
|
2478
|
+
return SyncOutcome(repo, "updated", f"would fast-forward {behind} commit(s)")
|
|
2479
|
+
merged = _checkout_git(repo, "merge", "--ff-only", "--quiet", f"origin/{default}")
|
|
2480
|
+
if merged.returncode != 0:
|
|
2481
|
+
return SyncOutcome(
|
|
2482
|
+
repo,
|
|
2483
|
+
"skipped",
|
|
2484
|
+
f"{behind} commit(s) behind, but local changes block a fast-forward: "
|
|
2485
|
+
f"{_first_line(merged)}",
|
|
2486
|
+
)
|
|
2487
|
+
sha = _checkout_git(repo, "rev-parse", "--short", "HEAD").stdout.strip()
|
|
2488
|
+
return SyncOutcome(repo, "updated", f"fast-forwarded {behind} commit(s) to {sha}")
|
|
2489
|
+
|
|
2490
|
+
|
|
2491
|
+
def sync_checkouts(paths: list[Path], dry_run: bool = False) -> list[str]:
|
|
2492
|
+
""":func:`sync_checkout` for each checkout, one line each; a git that
|
|
2493
|
+
hangs or is missing fails that checkout only."""
|
|
2494
|
+
lines = []
|
|
2495
|
+
for repo in paths:
|
|
2496
|
+
try:
|
|
2497
|
+
lines.append(sync_checkout(repo, dry_run=dry_run).line())
|
|
2498
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
2499
|
+
lines.append(SyncOutcome(Path(repo), "failed", f"git did not run: {exc}").line())
|
|
2500
|
+
return lines
|
|
2501
|
+
|
|
2502
|
+
|
|
2261
2503
|
class _Tee:
|
|
2262
2504
|
"""Write to the console and append to the log (scheduled runs)."""
|
|
2263
2505
|
|
|
@@ -2840,6 +3082,25 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
2840
3082
|
help="also register a daily unattended run at HH:MM (default 09:00) for this user",
|
|
2841
3083
|
)
|
|
2842
3084
|
ap.add_argument("--uninstall-schedule", action="store_true", help="remove the daily run")
|
|
3085
|
+
ap.add_argument(
|
|
3086
|
+
"--sync-checkouts",
|
|
3087
|
+
action="store_true",
|
|
3088
|
+
help=(
|
|
3089
|
+
"fast-forward each registered checkout that sits on its default branch to "
|
|
3090
|
+
"origin, when nothing local would be lost; no package upgrades"
|
|
3091
|
+
),
|
|
3092
|
+
)
|
|
3093
|
+
ap.add_argument(
|
|
3094
|
+
"--install-sync-schedule",
|
|
3095
|
+
nargs="?",
|
|
3096
|
+
type=int,
|
|
3097
|
+
const=SYNC_MINUTES,
|
|
3098
|
+
metavar="MINUTES",
|
|
3099
|
+
help=f"also register --sync-checkouts every MINUTES (default {SYNC_MINUTES})",
|
|
3100
|
+
)
|
|
3101
|
+
ap.add_argument(
|
|
3102
|
+
"--uninstall-sync-schedule", action="store_true", help="remove the checkout sync"
|
|
3103
|
+
)
|
|
2843
3104
|
ap.add_argument("--log", action="store_true", help=f"also append output to {LOG_FILE}")
|
|
2844
3105
|
ap.add_argument(
|
|
2845
3106
|
"--no-parity",
|
|
@@ -2935,7 +3196,13 @@ def _main(argv: Optional[list[str]] = None) -> int:
|
|
|
2935
3196
|
log_fh = _open_log()
|
|
2936
3197
|
sys.stdout = _Tee(sys.__stdout__, log_fh)
|
|
2937
3198
|
sys.stderr = _Tee(sys.__stderr__, log_fh)
|
|
2938
|
-
if should_self_update() and not {
|
|
3199
|
+
if should_self_update() and not {
|
|
3200
|
+
"-h",
|
|
3201
|
+
"--help",
|
|
3202
|
+
"--check",
|
|
3203
|
+
"--sync-only",
|
|
3204
|
+
"--sync-checkouts",
|
|
3205
|
+
} & set(raw):
|
|
2939
3206
|
if pre.dry_run:
|
|
2940
3207
|
print("self-update: skipped (dry run); this is the local copy's plan")
|
|
2941
3208
|
else:
|
|
@@ -2970,6 +3237,23 @@ def _main(argv: Optional[list[str]] = None) -> int:
|
|
|
2970
3237
|
|
|
2971
3238
|
if args.context_source and not args.context_sync:
|
|
2972
3239
|
raise SystemExit("--context-source needs --context-sync OWNER/REPO")
|
|
3240
|
+
if args.sync_checkouts:
|
|
3241
|
+
# Its own frequent schedule: only git fast-forwards, no upgrades.
|
|
3242
|
+
paths = [Path(p) for p in args.projects] or [
|
|
3243
|
+
e.path for e in read_registry(args.projects_file)
|
|
3244
|
+
]
|
|
3245
|
+
print("Checkouts:")
|
|
3246
|
+
for line in sync_checkouts(paths, dry_run=args.dry_run):
|
|
3247
|
+
print(f" {line}")
|
|
3248
|
+
return 0
|
|
3249
|
+
if args.uninstall_sync_schedule:
|
|
3250
|
+
print("Checkout sync:", uninstall_sync_schedule(dry_run=args.dry_run))
|
|
3251
|
+
if not args.projects and not args.add and args.install_sync_schedule is None:
|
|
3252
|
+
return 0
|
|
3253
|
+
if args.install_sync_schedule is not None:
|
|
3254
|
+
print("Checkout sync:", install_sync_schedule(args.install_sync_schedule, args.dry_run))
|
|
3255
|
+
if not args.projects and not args.add:
|
|
3256
|
+
return 0
|
|
2973
3257
|
# A pre-companion launcher (for example 0.2.65) refreshed only this module
|
|
2974
3258
|
# before handing over. Repair the complete installed release on this first
|
|
2975
3259
|
# full run, so the next offline --sync-only does not need another upgrade.
|
|
@@ -3113,6 +3397,12 @@ def _main(argv: Optional[list[str]] = None) -> int:
|
|
|
3113
3397
|
for line in lines:
|
|
3114
3398
|
print(f" {line}")
|
|
3115
3399
|
|
|
3400
|
+
# After the refresh, which may have merged roadmap PRs: bring each checkout
|
|
3401
|
+
# on its default branch up to origin so the editor shows them.
|
|
3402
|
+
print("\nCheckouts:")
|
|
3403
|
+
for line in sync_checkouts([e.path for e in entries], dry_run=args.dry_run):
|
|
3404
|
+
print(f" {line}")
|
|
3405
|
+
|
|
3116
3406
|
peers_ok = upgrade_peers(args)
|
|
3117
3407
|
failed = [r for r in results if not r.ok]
|
|
3118
3408
|
if failed:
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# src/roadmodel/__init__.py
|
|
2
|
-
__version__ = "0.2.
|
|
2
|
+
__version__ = "0.2.97"
|