rhylthyme-cli-runner 0.2.5a0__tar.gz → 0.2.8a0__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 (89) hide show
  1. {rhylthyme_cli_runner-0.2.5a0/src/rhylthyme_cli_runner.egg-info → rhylthyme_cli_runner-0.2.8a0}/PKG-INFO +15 -4
  2. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/README.md +11 -2
  3. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/setup.py +4 -2
  4. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/__init__.py +1 -1
  5. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/cli.py +67 -4
  6. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/recorder.py +36 -0
  7. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/import_cli.py +105 -3
  8. rhylthyme_cli_runner-0.2.8a0/src/rhylthyme_cli_runner/instruments.py +258 -0
  9. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/program_planner.py +30 -0
  10. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/program_runner.py +305 -6
  11. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/remote/checks.py +1 -1
  12. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/remote/cli.py +23 -1
  13. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/validate_program.py +19 -7
  14. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0/src/rhylthyme_cli_runner.egg-info}/PKG-INFO +15 -4
  15. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner.egg-info/SOURCES.txt +2 -0
  16. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner.egg-info/requires.txt +4 -1
  17. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_import_cli.py +71 -0
  18. rhylthyme_cli_runner-0.2.8a0/tests/test_instruments.py +1129 -0
  19. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_remote.py +62 -0
  20. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_validate_program.py +8 -0
  21. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/LICENSE +0 -0
  22. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/pyproject.toml +0 -0
  23. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/setup.cfg +0 -0
  24. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/environment_icons.py +0 -0
  25. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/environment_loader.py +0 -0
  26. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/environment_schemas.py +0 -0
  27. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/__init__.py +0 -0
  28. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/cli.py +0 -0
  29. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/compare.py +0 -0
  30. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/gold.py +0 -0
  31. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/harness.py +0 -0
  32. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/llm.py +0 -0
  33. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/matcher.py +0 -0
  34. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/metrics.py +0 -0
  35. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/patterns/__init__.py +0 -0
  36. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/patterns/authoring_guide.md +0 -0
  37. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/patterns/baseline.md +0 -0
  38. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/patterns/baseline.py +0 -0
  39. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/patterns/extract.py +0 -0
  40. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/patterns/four_turn.py +0 -0
  41. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/report.py +0 -0
  42. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/expand_replicates.py +0 -0
  43. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/__init__.py +0 -0
  44. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/calibrate.py +0 -0
  45. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/calibrate_cli.py +0 -0
  46. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/cli.py +0 -0
  47. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/evaluate.py +0 -0
  48. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/evaluate_cli.py +0 -0
  49. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/factors.py +0 -0
  50. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/hash.py +0 -0
  51. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/predict.py +0 -0
  52. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/render.py +0 -0
  53. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/replay.py +0 -0
  54. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/report.py +0 -0
  55. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/report_cli.py +0 -0
  56. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/store.py +0 -0
  57. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/synth.py +0 -0
  58. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/usable.py +0 -0
  59. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/instance_checks.py +0 -0
  60. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/remote/__init__.py +0 -0
  61. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/remote/auth.py +0 -0
  62. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/remote/mcp_client.py +0 -0
  63. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/render_cli.py +0 -0
  64. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner.egg-info/dependency_links.txt +0 -0
  65. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner.egg-info/entry_points.txt +0 -0
  66. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner.egg-info/top_level.txt +0 -0
  67. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_calibrate.py +0 -0
  68. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_cli.py +0 -0
  69. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_eval_baseline.py +0 -0
  70. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_eval_harness.py +0 -0
  71. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_eval_providers.py +0 -0
  72. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_eval_scorer.py +0 -0
  73. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_evaluate.py +0 -0
  74. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_example_validation.py +0 -0
  75. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_examples_integration.py +0 -0
  76. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_expand_replicates.py +0 -0
  77. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_hash_parity.py +0 -0
  78. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_history_report.py +0 -0
  79. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_mcp_checks.py +0 -0
  80. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_planner_time_strings.py +0 -0
  81. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_predict.py +0 -0
  82. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_program_runner.py +0 -0
  83. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_render_cli.py +0 -0
  84. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_replay.py +0 -0
  85. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_run_recorder.py +0 -0
  86. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_skill.py +0 -0
  87. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_synth.py +0 -0
  88. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_usable.py +0 -0
  89. {rhylthyme_cli_runner-0.2.5a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_validate_examples_ci.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rhylthyme-cli-runner
3
- Version: 0.2.5a0
3
+ Version: 0.2.8a0
4
4
  Summary: CLI runner for Rhylthyme real-time program schedules
5
5
  Home-page: https://github.com/rhylthyme/rhylthyme-cli-runner
6
6
  Author: Rhylthyme Team
@@ -26,7 +26,9 @@ Requires-Dist: click>=8.0.0
26
26
  Requires-Dist: jsonschema>=4.0.0
27
27
  Requires-Dist: pyyaml>=6.0
28
28
  Requires-Dist: colorama>=0.4.0
29
- Requires-Dist: rhylthyme-spec>=0.2.0a0
29
+ Requires-Dist: rhylthyme-spec>=0.2.1a0
30
+ Provides-Extra: galago
31
+ Requires-Dist: rhylthyme-galago>=0.1.0a0; extra == "galago"
30
32
  Provides-Extra: dev
31
33
  Requires-Dist: pytest>=6.0.0; extra == "dev"
32
34
  Requires-Dist: pytest-cov>=2.10.0; extra == "dev"
@@ -561,8 +563,17 @@ rhylthyme search "pad thai" -i spoonacular # then impo
561
563
  ```
562
564
 
563
565
  **Options:** `-i IMPORTER` (default: chosen from the URL), `-o PATH`,
564
- `--stdout`, `--publish` (and `--open`), `--no-validate`. `-` reads pasted
565
- source from stdin (with `-i`).
566
+ `--stdout`, `--publish` (and `--open`), `--no-validate`, `--review`. `-` reads
567
+ pasted source from stdin (with `-i`).
568
+
569
+ `--review` sends the import and its source to a model on the server (GPT-5.6
570
+ Luna, the model that related steps best in the paper's evaluation) and prints
571
+ what looks wrong: a default duration on "simmer until thick", a step the
572
+ parser dropped, steps chained that the source runs in parallel, a total that
573
+ disagrees with the source. It needs `rhylthyme login`, spends one model call
574
+ (capped per day), and changes nothing: the findings are yours to apply. On
575
+ rhylthyme.com the same review runs automatically after every import when you
576
+ are signed in.
566
577
 
567
578
  ### `rhylthyme render`
568
579
 
@@ -501,8 +501,17 @@ rhylthyme search "pad thai" -i spoonacular # then impo
501
501
  ```
502
502
 
503
503
  **Options:** `-i IMPORTER` (default: chosen from the URL), `-o PATH`,
504
- `--stdout`, `--publish` (and `--open`), `--no-validate`. `-` reads pasted
505
- source from stdin (with `-i`).
504
+ `--stdout`, `--publish` (and `--open`), `--no-validate`, `--review`. `-` reads
505
+ pasted source from stdin (with `-i`).
506
+
507
+ `--review` sends the import and its source to a model on the server (GPT-5.6
508
+ Luna, the model that related steps best in the paper's evaluation) and prints
509
+ what looks wrong: a default duration on "simmer until thick", a step the
510
+ parser dropped, steps chained that the source runs in parallel, a total that
511
+ disagrees with the source. It needs `rhylthyme login`, spends one model call
512
+ (capped per day), and changes nothing: the findings are yours to apply. On
513
+ rhylthyme.com the same review runs automatically after every import when you
514
+ are signed in.
506
515
 
507
516
  ### `rhylthyme render`
508
517
 
@@ -19,7 +19,7 @@ def read_readme():
19
19
 
20
20
  setup(
21
21
  name="rhylthyme-cli-runner",
22
- version="0.2.5a0",
22
+ version="0.2.8a0",
23
23
  description="CLI runner for Rhylthyme real-time program schedules",
24
24
  long_description=read_readme(),
25
25
  long_description_content_type="text/markdown",
@@ -38,9 +38,11 @@ setup(
38
38
  "jsonschema>=4.0.0",
39
39
  "pyyaml>=6.0",
40
40
  "colorama>=0.4.0",
41
- "rhylthyme-spec>=0.2.0a0",
41
+ "rhylthyme-spec>=0.2.1a0",
42
42
  ],
43
43
  extras_require={
44
+ # Instrument steps on galago-tools lab instruments (--workcell)
45
+ "galago": ["rhylthyme-galago>=0.1.0a0"],
44
46
  "dev": [
45
47
  # Testing framework
46
48
  "pytest>=6.0.0",
@@ -6,7 +6,7 @@ This package provides the command-line interface for running and validating
6
6
  Rhylthyme real-time program schedules.
7
7
  """
8
8
 
9
- __version__ = "0.2.5a0"
9
+ __version__ = "0.2.8a0"
10
10
  __author__ = "Rhylthyme Team"
11
11
  __description__ = "CLI runner for Rhylthyme real-time program schedules"
12
12
 
@@ -105,7 +105,18 @@ def cli(ctx, environments_dir):
105
105
  is_flag=True,
106
106
  help="Enforce all tasks must be defined in resourceConstraints (strict mode)",
107
107
  )
108
- def validate(program_files, schema, environment, verbose, json_output, strict):
108
+ @click.option(
109
+ "--workcell",
110
+ type=click.Path(exists=True, dir_okay=False),
111
+ default=None,
112
+ help=(
113
+ "Workcell file: check instrument steps against its galago tools "
114
+ "(tool names, types, commands and params)"
115
+ ),
116
+ )
117
+ def validate(
118
+ program_files, schema, environment, verbose, json_output, strict, workcell
119
+ ):
109
120
  """
110
121
  Validate one or more program files against the schema.
111
122
 
@@ -115,6 +126,8 @@ def validate(program_files, schema, environment, verbose, json_output, strict):
115
126
  Use --json to get machine-readable output for CI or scripting.
116
127
  Use --strict to require all tasks used in steps/buffers to be defined in resourceConstraints.
117
128
  Use -e/--environment to validate against specific environment constraints.
129
+ Use --workcell to check instrument steps against a lab's galago tools;
130
+ without it, commands are checked against each step's toolType.
118
131
  """
119
132
  # Set up environment for validation if specified
120
133
  if environment:
@@ -165,7 +178,7 @@ def validate(program_files, schema, environment, verbose, json_output, strict):
165
178
  all_valid = True
166
179
  for program_file in program_files:
167
180
  success = validate_program_file(
168
- program_file, schema, verbose, json_output, strict
181
+ program_file, schema, verbose, json_output, strict, workcell
169
182
  )
170
183
  if not success:
171
184
  all_valid = False
@@ -256,6 +269,28 @@ def validate(program_files, schema, environment, verbose, json_output, strict):
256
269
  "answers, which are what the record stores in context.userTags"
257
270
  ),
258
271
  )
272
+ @click.option(
273
+ "--workcell",
274
+ type=click.Path(exists=True, dir_okay=False),
275
+ default=None,
276
+ help=(
277
+ "Workcell file mapping the program's instrument tools to galago-tools "
278
+ "servers; required for programs with instrument steps"
279
+ ),
280
+ )
281
+ @click.option(
282
+ "--live",
283
+ is_flag=True,
284
+ help=(
285
+ "Run instrument steps on real hardware instead of galago's simulated "
286
+ "mode; shows what will run and asks first"
287
+ ),
288
+ )
289
+ @click.option(
290
+ "--confirm-live",
291
+ is_flag=True,
292
+ help="Answer the --live question in advance (for scripts)",
293
+ )
259
294
  def run(
260
295
  program_file,
261
296
  schema,
@@ -270,6 +305,9 @@ def run(
270
305
  history_file,
271
306
  no_history,
272
307
  predict_context,
308
+ workcell,
309
+ live,
310
+ confirm_live,
273
311
  ):
274
312
  """
275
313
  Run a program file with the interactive UI.
@@ -296,6 +334,11 @@ def run(
296
334
  --history FILE to read a specific corpus, --no-history to turn it off,
297
335
  and --predict-context KEY=VALUE to predict for a context other than the
298
336
  factor answers.
337
+
338
+ Programs with instrument steps need --workcell FILE (galago-tools servers,
339
+ via rhylthyme-galago). Tools run in galago's simulated mode unless you pass
340
+ --live, which lists the tools and commands and asks you to type 'live'
341
+ before anything is configured; --confirm-live answers for scripts.
299
342
  """
300
343
  run_program(
301
344
  program_file,
@@ -311,6 +354,9 @@ def run(
311
354
  history_file=history_file,
312
355
  use_history=not no_history,
313
356
  predict_context=predict_context,
357
+ workcell=workcell,
358
+ live=live,
359
+ confirm_live=confirm_live,
314
360
  )
315
361
 
316
362
 
@@ -324,7 +370,16 @@ def run(
324
370
  @click.option(
325
371
  "--verbose", "-v", is_flag=True, help="Show detailed planning information"
326
372
  )
327
- def plan(input_file, output_file, environment, verbose):
373
+ @click.option(
374
+ "--workcell",
375
+ type=click.Path(exists=True, dir_okay=False),
376
+ default=None,
377
+ help=(
378
+ "Workcell file: estimate instrument steps that have no duration from "
379
+ "their tools' EstimateDuration"
380
+ ),
381
+ )
382
+ def plan(input_file, output_file, environment, verbose, workcell):
328
383
  """
329
384
  Optimize a program schedule to reduce resource contention.
330
385
 
@@ -333,9 +388,17 @@ def plan(input_file, output_file, environment, verbose):
333
388
  starts to reduce contention at critical junctures.
334
389
 
335
390
  The optimized program is saved to the specified output file.
391
+
392
+ Instrument steps without a duration get an estimated one (flagged in
393
+ metadata.durationEstimate): from the tool with --workcell, else from a
394
+ duration-like command param, else a default.
336
395
  """
337
396
  success = plan_program(
338
- input_file, output_file, verbose, environment_file=environment
397
+ input_file,
398
+ output_file,
399
+ verbose,
400
+ environment_file=environment,
401
+ workcell=workcell,
339
402
  )
340
403
  if not success:
341
404
  sys.exit(1)
@@ -13,6 +13,8 @@ the events it emits are:
13
13
  ``step_aborted`` {step_id, time, reason}
14
14
  ``program_paused`` {time, wall_time}
15
15
  ``program_resumed`` {time, wall_time, paused_seconds}
16
+ ``program_aborted`` {time, reason} (reason goes to ``context.abortReason``)
17
+ ``instrument_reply`` {step_id, time, ok, code, errorMessage, metadata}
16
18
 
17
19
  All ``time`` values are the runner's program clock (an epoch float that
18
20
  advances at ``time_scale``); the record stores them as seconds from the
@@ -217,6 +219,8 @@ class RunRecorder:
217
219
  self.context: Dict[str, Any] = self._build_context()
218
220
 
219
221
  self.actuals: Dict[str, Dict[str, Any]] = {}
222
+ # step id -> {tool, command, replies: [{attempt, at, code, ...}]}
223
+ self.instrument_log: Dict[str, Dict[str, Any]] = {}
220
224
  self.paused_seconds: Dict[str, float] = {sid: 0.0 for sid in self.step_order}
221
225
  self._pause_wall: Optional[float] = None
222
226
  self._paused_running: List[str] = []
@@ -271,6 +275,31 @@ class RunRecorder:
271
275
  entry.setdefault("start", data["time"])
272
276
  entry["end"] = data["time"]
273
277
  entry["endedBy"] = "abort"
278
+ elif event_type == "instrument_reply":
279
+ sid = data["step_id"]
280
+ step = getattr(self.runner, "steps", {}).get(sid)
281
+ instrument = (getattr(step, "instrument", None) or {}) if step else {}
282
+ log = self.instrument_log.setdefault(
283
+ sid,
284
+ {
285
+ "tool": str(instrument.get("tool", "")),
286
+ "command": str(instrument.get("command", "")),
287
+ "replies": [],
288
+ },
289
+ )
290
+ reply: Dict[str, Any] = {
291
+ "attempt": getattr(step, "instrument_attempts", 0)
292
+ or len(log["replies"]) + 1,
293
+ "at": data["time"],
294
+ "code": str(data.get("code", "")),
295
+ }
296
+ if data.get("errorMessage"):
297
+ reply["errorMessage"] = str(data["errorMessage"])
298
+ if data.get("metadata"):
299
+ reply["metadata"] = copy.deepcopy(data["metadata"])
300
+ log["replies"].append(reply)
301
+ elif event_type == "program_aborted":
302
+ self.context["abortReason"] = data.get("reason") or "aborted"
274
303
  elif event_type == "program_paused":
275
304
  self._pause_wall = data.get("wall_time", time.time())
276
305
  self._paused_running = list(getattr(self.runner, "running_steps", []))
@@ -344,6 +373,13 @@ class RunRecorder:
344
373
  entry["endedBy"] = actual.get("endedBy", "executor")
345
374
  if "triggerFiredAt" in actual:
346
375
  entry["triggerFiredAt"] = rel(actual["triggerFiredAt"])
376
+ log = self.instrument_log.get(sid)
377
+ if log:
378
+ entry["instrument"] = {
379
+ "tool": log["tool"],
380
+ "command": log["command"],
381
+ "replies": [dict(r, at=rel(r["at"])) for r in log["replies"]],
382
+ }
347
383
  steps.append(entry)
348
384
 
349
385
  started_iso = _iso_utc(start_epoch) if start_epoch is not None else None
@@ -74,6 +74,10 @@ def _import_one(importer, source: str, text: Optional[str]):
74
74
  if hasattr(importer, "allow_local_files"):
75
75
  importer.allow_local_files = True
76
76
  return importer.import_from_url(str(path))
77
+ if not source.startswith(("http://", "https://")) and (
78
+ path.suffix or "/" in source
79
+ ):
80
+ raise click.ClickException(f"No such file: {source}")
77
81
  return importer.import_from_url(source)
78
82
 
79
83
 
@@ -130,8 +134,21 @@ def _validation(program: dict):
130
134
  @click.option(
131
135
  "--no-validate", is_flag=True, help="Skip validation of the imported program."
132
136
  )
137
+ @click.option(
138
+ "--review",
139
+ "do_review",
140
+ is_flag=True,
141
+ help="Have a model read the import against its source and report what looks wrong (needs `rhylthyme login`).",
142
+ )
133
143
  def import_command(
134
- source, importer_name, output, to_stdout, do_publish, open_url, no_validate
144
+ source,
145
+ importer_name,
146
+ output,
147
+ to_stdout,
148
+ do_publish,
149
+ open_url,
150
+ no_validate,
151
+ do_review,
135
152
  ):
136
153
  """Import a recipe, protocol or deck as a program.
137
154
 
@@ -141,6 +158,7 @@ def import_command(
141
158
  .cook or .pptx file. `-` reads the source text from stdin (with -i).
142
159
  rhylthyme import https://www.seriouseats.com/the-best-chili-recipe
143
160
  rhylthyme import 52772 -i themealdb --publish
161
+ rhylthyme import recipe.cook -i cooklang --review # a second opinion on durations, order, gaps
144
162
  rhylthyme import https://raw.githubusercontent.com/Opentrons/Protocols/develop/protocols/007992/rna_isolation.ot2.apiv2.py
145
163
  rhylthyme importers # what is installed
146
164
  """
@@ -153,8 +171,22 @@ def import_command(
153
171
  )
154
172
  text = sys.stdin.read()
155
173
  importer = _pick(registry, source, importer_name)
174
+ token = None
175
+ if do_review:
176
+ # Before any work: the review needs an account, say so now.
177
+ from .remote import auth
178
+
179
+ try:
180
+ token = auth.access_token()
181
+ except auth.AuthError as e:
182
+ raise click.ClickException(
183
+ f"{e} (--review sends the import to a model on the server, which needs your account.)"
184
+ )
156
185
  click.echo(f"Importing with {importer.name}…", err=True)
157
186
  result = _import_one(importer, source, text)
187
+ source_text = (
188
+ text if text is not None else _source_text_for_review(importer, source)
189
+ )
158
190
  if not result.success:
159
191
  raise click.ClickException(result.error or "import failed")
160
192
  program = result.program
@@ -163,9 +195,10 @@ def import_command(
163
195
  v = _validation(program)
164
196
  errors = v.get("errors") or v.get("logic_errors") or []
165
197
  if not v.get("is_valid", v.get("valid", True)):
166
- for e in errors[:8]:
198
+ for finding in errors[:8]:
167
199
  click.echo(
168
- f" ✗ {e if isinstance(e, str) else e.get('message', e)}", err=True
200
+ f" ✗ {finding if isinstance(finding, str) else finding.get('message', finding)}",
201
+ err=True,
169
202
  )
170
203
  raise click.ClickException(
171
204
  "The imported program does not validate; not published. Re-run with --no-validate to keep it anyway."
@@ -187,6 +220,14 @@ def import_command(
187
220
  target.write_text(payload + "\n")
188
221
  click.echo(f"Saved {target}", err=True)
189
222
 
223
+ if do_review:
224
+ _review(
225
+ program,
226
+ token,
227
+ source_text=source_text,
228
+ source_url=source if source.startswith(("http://", "https://")) else None,
229
+ )
230
+
190
231
  if do_publish:
191
232
  from .remote.cli import publish as publish_command
192
233
 
@@ -208,6 +249,67 @@ def import_command(
208
249
  Path(tmp_path).unlink(missing_ok=True)
209
250
 
210
251
 
252
+ def _source_text_for_review(importer, source: str) -> Optional[str]:
253
+ """The text the import came from, when the CLI has it: a local file, or
254
+ a URL the CLI fetched for a text importer. Recipe-site imports do not
255
+ keep the page, so the reviewer gets the URL instead."""
256
+ path = Path(source)
257
+ if path.is_file() and path.suffix.lower() in (".cook", ".py", ".txt", ".md"):
258
+ try:
259
+ return path.read_text()
260
+ except OSError:
261
+ return None
262
+ if (
263
+ source.startswith(("http://", "https://"))
264
+ and hasattr(importer, "import_from_source")
265
+ and not getattr(importer, "supported_domains", None)
266
+ ):
267
+ try:
268
+ from .remote.cli import _fetch_bytes
269
+
270
+ return _fetch_bytes(source).decode("utf-8")
271
+ except Exception: # noqa: BLE001
272
+ return None
273
+ return None
274
+
275
+
276
+ def _review(
277
+ program: dict, token: str, *, source_text: Optional[str], source_url: Optional[str]
278
+ ) -> None:
279
+ from .remote import mcp_client as MC
280
+
281
+ click.echo("Reviewing…", err=True)
282
+ client = MC.McpClient(url=MC.endpoint_for(None))
283
+ args = {"program": program, "token": token}
284
+ if source_text:
285
+ args["source_text"] = source_text
286
+ elif source_url:
287
+ args["source_url"] = source_url
288
+ try:
289
+ result = client.call_tool("review_program", args, timeout=150)
290
+ except MC.ToolError as e:
291
+ raise click.ClickException(f"Review failed: {e.text}")
292
+ except MC.McpError as e:
293
+ raise click.ClickException(f"Review failed: {e}")
294
+ review = result.structured or {}
295
+ findings = review.get("findings") or []
296
+ mark = {"error": "✗", "warning": "!", "note": "·"}
297
+ click.echo("")
298
+ click.echo(
299
+ review.get("summary")
300
+ or ("Review: no problems found." if not findings else "Review:")
301
+ )
302
+ for f in findings:
303
+ where = f" [{f['stepId']}]" if f.get("stepId") else ""
304
+ click.echo(f" {mark.get(f.get('severity'), '·')}{where} {f.get('message')}")
305
+ if f.get("suggestion"):
306
+ click.echo(f" → {f['suggestion']}")
307
+ if findings:
308
+ click.echo(
309
+ "\nThe file is unchanged; edit it and `rhylthyme validate` again.", err=True
310
+ )
311
+
312
+
211
313
  @click.command("search")
212
314
  @click.argument("query")
213
315
  @click.option(