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.
Files changed (74) hide show
  1. {roadmodel-0.2.95 → roadmodel-0.2.97}/PKG-INFO +1 -1
  2. {roadmodel-0.2.95/roadmodel/data → roadmodel-0.2.97/docs/claude-commands}/roadmap-refresh.md +9 -1
  3. {roadmodel-0.2.95/roadmodel/data → roadmodel-0.2.97/docs/claude-commands}/roadmap-step.md +7 -2
  4. {roadmodel-0.2.95/roadmodel/data → roadmodel-0.2.97/docs/claude-commands}/roadmodel-upgrade.md +19 -1
  5. {roadmodel-0.2.95/roadmodel/data → roadmodel-0.2.97/docs/templates}/phase-roadmap-template.md +12 -5
  6. {roadmodel-0.2.95 → roadmodel-0.2.97}/pyproject.toml +1 -1
  7. {roadmodel-0.2.95/docs/templates → roadmodel-0.2.97/roadmodel/data}/phase-roadmap-template.md +12 -5
  8. {roadmodel-0.2.95/docs/claude-commands → roadmodel-0.2.97/roadmodel/data}/roadmap-refresh.md +9 -1
  9. {roadmodel-0.2.95/docs/claude-commands → roadmodel-0.2.97/roadmodel/data}/roadmap-step.md +7 -2
  10. {roadmodel-0.2.95/docs/claude-commands → roadmodel-0.2.97/roadmodel/data}/roadmodel-upgrade.md +19 -1
  11. {roadmodel-0.2.95 → roadmodel-0.2.97}/scripts/update_projects.py +291 -1
  12. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/__init__.py +1 -1
  13. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/cli.py +174 -0
  14. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/ladder_layout.py +156 -3
  15. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/scoring.py +78 -81
  16. {roadmodel-0.2.95 → roadmodel-0.2.97}/.github/README.md +0 -0
  17. {roadmodel-0.2.95 → roadmodel-0.2.97}/.gitignore +0 -0
  18. {roadmodel-0.2.95 → roadmodel-0.2.97}/LICENSE +0 -0
  19. {roadmodel-0.2.95 → roadmodel-0.2.97}/NOTICE +0 -0
  20. {roadmodel-0.2.95 → roadmodel-0.2.97}/README.md +0 -0
  21. {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/benchmarks.json +0 -0
  22. {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/catalog.json +0 -0
  23. {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/claude-commands/roadmap-phase.md +0 -0
  24. {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/claude-commands/roadmap-project.md +0 -0
  25. {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/context-windows.json +0 -0
  26. {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/effort-tokens.json +0 -0
  27. {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/model-selector.txt +0 -0
  28. {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/model-tier-cost-scale.md +0 -0
  29. {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/settings-display.md +0 -0
  30. {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/templates/planning-kit-how-to-use.md +0 -0
  31. {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/templates/project-roadmap-template.md +0 -0
  32. {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/templates/prompt-phase-roadmap.md +0 -0
  33. {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/templates/prompt-project-roadmap.md +0 -0
  34. {roadmodel-0.2.95 → roadmodel-0.2.97}/docs/user-context.example.md +0 -0
  35. {roadmodel-0.2.95 → roadmodel-0.2.97}/hatch_build.py +0 -0
  36. {roadmodel-0.2.95 → roadmodel-0.2.97}/infra/README.md +0 -0
  37. {roadmodel-0.2.95 → roadmodel-0.2.97}/infra/gcp-killswitch/README.md +0 -0
  38. {roadmodel-0.2.95 → roadmodel-0.2.97}/infra/supabase/README.md +0 -0
  39. {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/benchmarks.json +0 -0
  40. {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/catalog.json +0 -0
  41. {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/context-windows.json +0 -0
  42. {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/effort-tokens.json +0 -0
  43. {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/model-selector.txt +0 -0
  44. {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/model-tier-cost-scale.md +0 -0
  45. {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/planning-kit-how-to-use.md +0 -0
  46. {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/project-roadmap-template.md +0 -0
  47. {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/prompt-phase-roadmap.md +0 -0
  48. {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/prompt-project-roadmap.md +0 -0
  49. {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/roadmap-phase.md +0 -0
  50. {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/roadmap-project.md +0 -0
  51. {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/settings-display.md +0 -0
  52. {roadmodel-0.2.95 → roadmodel-0.2.97}/roadmodel/data/user-context.example.md +0 -0
  53. {roadmodel-0.2.95 → roadmodel-0.2.97}/scripts/agent_context.py +0 -0
  54. {roadmodel-0.2.95 → roadmodel-0.2.97}/scripts/agent_rules.py +0 -0
  55. {roadmodel-0.2.95 → roadmodel-0.2.97}/scripts/updater_lock.py +0 -0
  56. {roadmodel-0.2.95 → roadmodel-0.2.97}/scripts/upgrade_fleet.py +0 -0
  57. {roadmodel-0.2.95 → roadmodel-0.2.97}/scripts/worktree_memory.py +0 -0
  58. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/__main__.py +0 -0
  59. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/config.py +0 -0
  60. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/cost.py +0 -0
  61. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/errors.py +0 -0
  62. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/mcp_server.py +0 -0
  63. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/private_files.py +0 -0
  64. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/providers/__init__.py +0 -0
  65. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/providers/_safety.py +0 -0
  66. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/providers/anthropic.py +0 -0
  67. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/providers/google.py +0 -0
  68. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/providers/openai.py +0 -0
  69. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/providers/openai_compatible.py +0 -0
  70. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/providers/registry.py +0 -0
  71. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/recommend.py +0 -0
  72. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/series.py +0 -0
  73. {roadmodel-0.2.95 → roadmodel-0.2.97}/src/roadmodel/usage.py +0 -0
  74. {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.95
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
@@ -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. A
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. A 0.2.94 ladder is three lists instead (`- Pick:`,
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`, else the Cost column's Pick.
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
 
@@ -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
@@ -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. The ranks follow the column's own rule, not the
778
- AA Index: Cost ranks the cheapest first, so its AA Index
779
- may rise down the column. The Backup reads `none` when
780
- the Runner-up already holds the
781
- only other maker's models. Every model is the latest
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "roadmodel"
7
- version = "0.2.95"
7
+ version = "0.2.97"
8
8
  description = "BYO-key CLI that recommends the right AI model, platform, and settings for a prompt."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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. The ranks follow the column's own rule, not the
778
- AA Index: Cost ranks the cheapest first, so its AA Index
779
- may rise down the column. The Backup reads `none` when
780
- the Runner-up already holds the
781
- only other maker's models. Every model is the latest
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
@@ -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. A
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. A 0.2.94 ladder is three lists instead (`- Pick:`,
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`, else the Cost column's Pick.
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
 
@@ -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 {"-h", "--help", "--check", "--sync-only"} & set(raw):
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.95"
2
+ __version__ = "0.2.97"