@jenga-ai/agent 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +28 -10
  2. package/agents/scrum-master.md +75 -0
  3. package/mcp/router/embedder.js +1 -1
  4. package/mcp/training_runner/index.js +239 -0
  5. package/mcp/training_runner/package-lock.json +1065 -0
  6. package/mcp/training_runner/package.json +15 -0
  7. package/package.json +13 -11
  8. package/skills/close-story/SKILL.md +203 -0
  9. package/skills/close-story/scripts/check-story-closeable.sh +195 -0
  10. package/skills/close-story/scripts/compute-scope-divergence.sh +128 -0
  11. package/skills/close-story/scripts/extract-diff-stats.sh +48 -0
  12. package/skills/close-story/scripts/extract-task-diff-stats.sh +97 -0
  13. package/skills/close-story/scripts/update-task-frontmatter.sh +103 -0
  14. package/skills/commit/SKILL.md +18 -0
  15. package/skills/distribute/CONFIG_SCHEMA.md +90 -0
  16. package/skills/distribute/SKILL.md +173 -0
  17. package/skills/distribute/scripts/check-version.sh +74 -0
  18. package/skills/distribute/scripts/commit-version-bump.sh +108 -0
  19. package/skills/distribute/scripts/distribute-changes.sh +381 -0
  20. package/skills/do/SKILL.md +314 -0
  21. package/skills/do/assets/intent-vs-diff-prompt.md +69 -0
  22. package/skills/doc/assets/path-objectives.yaml +13 -0
  23. package/skills/init/SKILL.md +4 -3
  24. package/skills/init/assets/strategy_stub_template.md +38 -0
  25. package/skills/init/scripts/init.sh +6 -1
  26. package/skills/jenga/SKILL.md +51 -2
  27. package/skills/strategy/SKILL.md +312 -0
  28. package/templates/SCRUM_BOARD_SCHEMA.md +49 -0
  29. package/skills/train/SKILL.md +0 -116
  30. package/skills/train/assets/dashboard-templates/classifiers.html +0 -106
  31. package/skills/train/assets/dashboard-templates/nlp.html +0 -102
  32. package/skills/train/assets/dashboard-templates/transformers.html +0 -98
  33. package/skills/train/assets/results-parsers/__init__.py +0 -9
  34. package/skills/train/assets/results-parsers/classifiers.py +0 -84
  35. package/skills/train/assets/results-parsers/nlp.py +0 -88
  36. package/skills/train/assets/results-parsers/reporter.py +0 -154
  37. package/skills/train/assets/results-parsers/transformers.py +0 -120
  38. package/skills/train/train_cli.py +0 -786
@@ -1,786 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- train_cli.py — Two-phase ML training job orchestrator.
4
-
5
- Subcommands:
6
- new <type> <job-name> Scaffold a job from a template
7
- new --interactive [--full] Launch wizard to scaffold and configure interactively
8
- run <job-dir> Execute validate.py -> train.py --smoke pipeline
9
- """
10
- import argparse
11
- import shutil
12
- import signal
13
- import subprocess
14
- import sys
15
- from pathlib import Path
16
-
17
- try:
18
- import yaml
19
- _YAML_AVAILABLE = True
20
- except ImportError:
21
- _YAML_AVAILABLE = False
22
-
23
- VALID_TYPES = ["classifiers", "transformers", "nlp"]
24
- REPO_ROOT = Path(__file__).resolve().parent.parent.parent
25
-
26
- # ---------------------------------------------------------------------------
27
- # Wizard field schemas
28
- # Each entry: {"key": "dot.notation.path", "prompt": "...", "hint": "...", "default": "..."}
29
- # Fields listed under "critical" are shown with --interactive.
30
- # Fields under "full" are ADDITIONAL fields shown when --full is also passed.
31
- # ---------------------------------------------------------------------------
32
- WIZARD_FIELDS = {
33
- "classifiers": {
34
- "critical": [
35
- {
36
- "key": "data.train_file",
37
- "prompt": "Training data file path",
38
- "hint": "Path to CSV file with training samples",
39
- "default": "input/data/train.csv",
40
- },
41
- {
42
- "key": "data.target_column",
43
- "prompt": "Target column name",
44
- "hint": "Column in the CSV that contains class labels",
45
- "default": "label",
46
- },
47
- {
48
- "key": "model.type",
49
- "prompt": "Classifier algorithm",
50
- "hint": "One of: random_forest, gradient_boosting, svm, logistic_regression, xgboost",
51
- "default": "random_forest",
52
- },
53
- {
54
- "key": "model.params.n_estimators",
55
- "prompt": "Number of estimators",
56
- "hint": "Trees for random_forest / gradient_boosting (ignored for svm / logistic_regression)",
57
- "default": "100",
58
- },
59
- {
60
- "key": "data.test_size",
61
- "prompt": "Test split ratio",
62
- "hint": "Fraction of data held out for testing, e.g. 0.2 = 20%",
63
- "default": "0.2",
64
- },
65
- ],
66
- "full": [
67
- {
68
- "key": "data.test_file",
69
- "prompt": "Test data file path",
70
- "hint": "Separate test CSV; leave blank to auto-split from train_file",
71
- "default": "input/data/test.csv",
72
- },
73
- {
74
- "key": "model.params.max_depth",
75
- "prompt": "Max tree depth",
76
- "hint": "Maximum depth of trees; leave blank for unlimited",
77
- "default": "",
78
- },
79
- {
80
- "key": "model.params.random_state",
81
- "prompt": "Random state seed",
82
- "hint": "Integer seed for reproducibility",
83
- "default": "42",
84
- },
85
- {
86
- "key": "training.cross_validation",
87
- "prompt": "Enable cross-validation?",
88
- "hint": "true or false",
89
- "default": "true",
90
- },
91
- {
92
- "key": "training.cv_folds",
93
- "prompt": "Cross-validation folds",
94
- "hint": "Number of folds (e.g. 5)",
95
- "default": "5",
96
- },
97
- ],
98
- },
99
- "transformers": {
100
- "critical": [
101
- {
102
- "key": "model.name",
103
- "prompt": "HuggingFace model ID",
104
- "hint": "Model hub identifier, e.g. bert-base-uncased or roberta-base",
105
- "default": "bert-base-uncased",
106
- },
107
- {
108
- "key": "model.task",
109
- "prompt": "Fine-tuning task",
110
- "hint": "One of: text-classification, token-classification, question-answering, seq2seq",
111
- "default": "text-classification",
112
- },
113
- {
114
- "key": "data.train_file",
115
- "prompt": "Training data file path",
116
- "hint": "Path to .jsonl file with training samples",
117
- "default": "input/data/train.jsonl",
118
- },
119
- {
120
- "key": "training.num_train_epochs",
121
- "prompt": "Number of training epochs",
122
- "hint": "How many full passes over the training data",
123
- "default": "3",
124
- },
125
- {
126
- "key": "training.learning_rate",
127
- "prompt": "Learning rate",
128
- "hint": "AdamW learning rate, e.g. 2e-5",
129
- "default": "2e-5",
130
- },
131
- ],
132
- "full": [
133
- {
134
- "key": "data.eval_file",
135
- "prompt": "Evaluation data file path",
136
- "hint": "Path to .jsonl evaluation set",
137
- "default": "input/data/eval.jsonl",
138
- },
139
- {
140
- "key": "data.text_column",
141
- "prompt": "Text column name",
142
- "hint": "Key in each JSON record that holds the input text",
143
- "default": "text",
144
- },
145
- {
146
- "key": "data.label_column",
147
- "prompt": "Label column name",
148
- "hint": "Key in each JSON record that holds the label",
149
- "default": "label",
150
- },
151
- {
152
- "key": "data.max_length",
153
- "prompt": "Max token sequence length",
154
- "hint": "Sequences are truncated / padded to this length",
155
- "default": "128",
156
- },
157
- {
158
- "key": "training.per_device_train_batch_size",
159
- "prompt": "Train batch size (per device)",
160
- "hint": "Number of samples per GPU/CPU per step",
161
- "default": "16",
162
- },
163
- {
164
- "key": "training.warmup_steps",
165
- "prompt": "Warmup steps",
166
- "hint": "Linear LR warmup before reaching the target learning rate",
167
- "default": "500",
168
- },
169
- {
170
- "key": "training.weight_decay",
171
- "prompt": "Weight decay",
172
- "hint": "L2 regularisation coefficient",
173
- "default": "0.01",
174
- },
175
- ],
176
- },
177
- "nlp": {
178
- "critical": [
179
- {
180
- "key": "model.name",
181
- "prompt": "Base model name",
182
- "hint": "spaCy model (e.g. en_core_web_sm) or HuggingFace model ID",
183
- "default": "en_core_web_sm",
184
- },
185
- {
186
- "key": "model.task",
187
- "prompt": "NLP task",
188
- "hint": "One of: ner, text-classification, pos-tagging, dependency-parsing",
189
- "default": "ner",
190
- },
191
- {
192
- "key": "data.train_file",
193
- "prompt": "Training data file path",
194
- "hint": "Path to training data (.spacy, .jsonl, or .conll)",
195
- "default": "input/data/train.spacy",
196
- },
197
- {
198
- "key": "training.n_iter",
199
- "prompt": "Training iterations",
200
- "hint": "Number of passes over the training data",
201
- "default": "30",
202
- },
203
- {
204
- "key": "training.learning_rate",
205
- "prompt": "Learning rate",
206
- "hint": "Optimiser learning rate, e.g. 1e-3",
207
- "default": "1e-3",
208
- },
209
- ],
210
- "full": [
211
- {
212
- "key": "data.eval_file",
213
- "prompt": "Evaluation data file path",
214
- "hint": "Path to evaluation data file",
215
- "default": "input/data/eval.spacy",
216
- },
217
- {
218
- "key": "training.batch_size",
219
- "prompt": "Batch size",
220
- "hint": "Number of examples per training batch",
221
- "default": "32",
222
- },
223
- {
224
- "key": "training.dropout",
225
- "prompt": "Dropout rate",
226
- "hint": "Fraction of activations to randomly zero during training",
227
- "default": "0.2",
228
- },
229
- ],
230
- },
231
- }
232
-
233
-
234
- CONVERT_EXTENSIONS = {".json", ".jsonl", ".yaml", ".yml"}
235
-
236
-
237
- def _auto_convert_data_file(file_path_str):
238
- """
239
- If file_path_str points to a non-CSV file supported by /convert, invoke convert_cli.py
240
- automatically and return the resulting CSV path. Returns file_path_str unchanged when:
241
- - the extension is already .csv
242
- - the file does not exist (defer the error to the training job itself)
243
- Aborts the wizard (sys.exit) if conversion fails.
244
- """
245
- path = Path(file_path_str)
246
- ext = path.suffix.lower()
247
-
248
- if ext not in CONVERT_EXTENSIONS:
249
- return file_path_str # .csv or unknown — pass-through
250
-
251
- if not path.exists():
252
- print(f" ⚠️ File not found: {file_path_str} — skipping auto-conversion.")
253
- return file_path_str
254
-
255
- convert_script = REPO_ROOT / "skills" / "convert" / "convert_cli.py"
256
- print(f"\n 🔄 Non-CSV file detected ({ext}). Auto-converting with /convert…")
257
-
258
- result = subprocess.run(
259
- [sys.executable, str(convert_script), str(path.resolve()), "--yes"],
260
- capture_output=True,
261
- text=True,
262
- )
263
-
264
- if result.returncode != 0:
265
- print("\n❌ Auto-conversion failed. Aborting wizard.")
266
- # Show the most relevant line from stderr (last non-empty line = the actual error)
267
- stderr_lines = [l for l in result.stderr.strip().splitlines() if l.strip()]
268
- stdout_lines = [l for l in result.stdout.strip().splitlines() if l.strip()]
269
- for line in stdout_lines:
270
- print(f" {line}")
271
- if stderr_lines:
272
- print(f" Error: {stderr_lines[-1].strip()}")
273
- print(" Fix the data file and run the wizard again.")
274
- sys.exit(1)
275
-
276
- csv_path = str(path.resolve().with_suffix(".csv"))
277
- print(f" ✓ Converted to CSV: {csv_path}")
278
- return csv_path
279
-
280
-
281
- def _set_nested(d, dotted_key, value):
282
- """Set a value in a nested dict using a dot-separated key path."""
283
- keys = dotted_key.split(".")
284
- for k in keys[:-1]:
285
- d = d.setdefault(k, {})
286
- leaf = keys[-1]
287
- # Coerce type based on existing value when possible
288
- existing = d.get(leaf)
289
- if existing is not None:
290
- try:
291
- if isinstance(existing, bool):
292
- value = value.lower() in ("true", "yes", "1")
293
- elif isinstance(existing, int):
294
- value = int(value) if value != "" else None
295
- elif isinstance(existing, float):
296
- value = float(value) if value != "" else None
297
- except (ValueError, AttributeError):
298
- pass
299
- else:
300
- # Coerce from string when no existing type context
301
- if isinstance(value, str):
302
- if value.lower() in ("true", "false"):
303
- value = value.lower() == "true"
304
- else:
305
- for coerce in (int, float):
306
- try:
307
- value = coerce(value)
308
- break
309
- except ValueError:
310
- pass
311
- d[leaf] = value if value != "" else None
312
-
313
-
314
- def _prompt(field):
315
- """Display a single prompt with hint and return the user's answer (or default)."""
316
- hint = field["hint"]
317
- default = field["default"]
318
- default_display = f" [{default}]" if default else ""
319
- line = f" {field['prompt']}{default_display}\n ↳ {hint}\n > "
320
- try:
321
- answer = input(line).strip()
322
- except EOFError:
323
- answer = ""
324
- return answer if answer else default
325
-
326
-
327
- def _scaffold_job(job_type, job_name):
328
- """Copy the template into jobs/<job_name>. Returns the destination Path."""
329
- template_dir = REPO_ROOT / ".training" / "template" / job_type
330
- if not template_dir.exists():
331
- print(f"❌ Template directory not found: {template_dir}")
332
- sys.exit(1)
333
-
334
- jobs_dir = REPO_ROOT / "jobs"
335
- jobs_dir.mkdir(exist_ok=True)
336
-
337
- dest = jobs_dir / job_name
338
- if dest.exists():
339
- suffix = 1
340
- while (jobs_dir / f"{job_name}-{suffix}").exists():
341
- suffix += 1
342
- dest = jobs_dir / f"{job_name}-{suffix}"
343
-
344
- shutil.copytree(str(template_dir), str(dest))
345
- _stamp_template_version(dest, job_type)
346
- return dest
347
-
348
-
349
- def _stamp_template_version(dest, job_type):
350
- """Stamp the scaffolded job's config.yaml with the current manifest version."""
351
- if not _YAML_AVAILABLE:
352
- return
353
- manifest_path = REPO_ROOT / ".training" / "template" / "manifest.json"
354
- try:
355
- import json
356
- with open(manifest_path) as f:
357
- manifest = json.load(f)
358
- version = manifest["templates"][job_type]["version"]
359
- except Exception as e:
360
- print(f"⚠️ Could not read template manifest — template_version not stamped: {e}")
361
- return
362
-
363
- config_path = dest / "input" / "config.yaml"
364
- if not config_path.exists():
365
- return
366
- try:
367
- with open(config_path) as f:
368
- config = yaml.safe_load(f) or {}
369
- config["template_version"] = version
370
- with open(config_path, "w") as f:
371
- yaml.dump(config, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
372
- except Exception as e:
373
- print(f"⚠️ Could not stamp template_version in config.yaml: {e}")
374
-
375
-
376
- def _patch_config(dest, job_type, responses):
377
- """Apply wizard responses to the scaffolded config.yaml."""
378
- if not _YAML_AVAILABLE:
379
- print("⚠️ PyYAML not available — skipping config.yaml update. Install with: pip install pyyaml")
380
- return
381
-
382
- config_path = dest / "input" / "config.yaml"
383
- if not config_path.exists():
384
- print(f"⚠️ config.yaml not found at {config_path} — skipping patch.")
385
- return
386
-
387
- with open(config_path) as f:
388
- config = yaml.safe_load(f) or {}
389
-
390
- for key, value in responses.items():
391
- _set_nested(config, key, value)
392
-
393
- with open(config_path, "w") as f:
394
- yaml.dump(config, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
395
-
396
-
397
- def run_wizard(full=False):
398
- """Interactive wizard for `train new`. Returns after scaffolding + patching config."""
399
- scaffold_dir = None # set after scaffolding so SIGINT handler can reference it
400
-
401
- def _sigint_cleanup(signum, frame):
402
- print("\n\n⚠️ Wizard interrupted.")
403
- if scaffold_dir and scaffold_dir.exists():
404
- try:
405
- answer = input(f" Delete scaffolded directory '{scaffold_dir}'? [y/N] ").strip().lower()
406
- except (EOFError, OSError):
407
- answer = "n"
408
- if answer in ("y", "yes"):
409
- shutil.rmtree(str(scaffold_dir))
410
- print(f"🗑️ Deleted {scaffold_dir}")
411
- else:
412
- print(f"📁 Kept {scaffold_dir}")
413
- sys.exit(0)
414
-
415
- original_sigint = signal.getsignal(signal.SIGINT)
416
- signal.signal(signal.SIGINT, _sigint_cleanup)
417
-
418
- try:
419
- print("\n🧙 Train New — Interactive Wizard")
420
- print(" Press Enter to accept defaults | Ctrl+C to cancel\n")
421
-
422
- # --- Step 1: Job name ---
423
- job_name = ""
424
- while not job_name:
425
- job_name = input(" Job name [e.g. my-classifier]\n ↳ Unique name for the new job directory under jobs/\n > ").strip()
426
- if not job_name:
427
- print(" ⚠️ Job name cannot be empty.\n")
428
-
429
- # --- Step 2: Job type ---
430
- job_type = ""
431
- while job_type not in VALID_TYPES:
432
- raw = input(
433
- f" Job type [{'/'.join(VALID_TYPES)}]\n"
434
- " ↳ Template to scaffold: classifiers (sklearn), transformers (HuggingFace), nlp (spaCy)\n"
435
- " > "
436
- ).strip().lower()
437
- if raw in VALID_TYPES:
438
- job_type = raw
439
- else:
440
- print(f" ⚠️ Must be one of: {', '.join(VALID_TYPES)}\n")
441
-
442
- # --- Step 3: Scaffold ---
443
- scaffold_dir = _scaffold_job(job_type, job_name)
444
- print(f"\n✅ Scaffolded → {scaffold_dir.relative_to(REPO_ROOT)}/\n")
445
- print(" Now configuring key fields in config.yaml…\n")
446
-
447
- # --- Step 4: Collect config field responses ---
448
- schema = WIZARD_FIELDS[job_type]
449
- fields_to_prompt = list(schema["critical"])
450
- if full:
451
- fields_to_prompt += schema["full"]
452
-
453
- responses = {}
454
- for field in fields_to_prompt:
455
- value = _prompt(field)
456
- if value: # only update if non-empty
457
- # Auto-convert non-CSV data files before writing config
458
- if field["key"] == "data.train_file":
459
- value = _auto_convert_data_file(value)
460
- responses[field["key"]] = value
461
-
462
- # --- Step 5: Patch config.yaml ---
463
- _patch_config(scaffold_dir, job_type, responses)
464
-
465
- print(f"\n✅ Job '{scaffold_dir.name}' is ready at {scaffold_dir.relative_to(REPO_ROOT)}/")
466
- print(f" config.yaml has been updated with your choices.")
467
- print(f" Run: python skills/train/train_cli.py run jobs/{scaffold_dir.name}\n")
468
-
469
- except KeyboardInterrupt:
470
- # Fallback in case signal handler wasn't triggered (e.g. within input())
471
- _sigint_cleanup(None, None)
472
- finally:
473
- signal.signal(signal.SIGINT, original_sigint)
474
-
475
-
476
- # ---------------------------------------------------------------------------
477
- # CLI flag → config.yaml key mapping by job type
478
- # ---------------------------------------------------------------------------
479
- _FLAG_KEY_MAP = {
480
- "classifiers": {
481
- "model": "model.type",
482
- "epochs": None, # no epochs concept for sklearn classifiers
483
- "batch_size": None,
484
- },
485
- "transformers": {
486
- "model": "model.name",
487
- "epochs": "training.num_train_epochs",
488
- "batch_size": "training.per_device_train_batch_size",
489
- },
490
- "nlp": {
491
- "model": "model.name",
492
- "epochs": "training.n_iter",
493
- "batch_size": "training.batch_size",
494
- },
495
- }
496
-
497
-
498
- def _apply_cli_flags(dest, job_type, args):
499
- """Merge --model / --epochs / --batch-size into config.yaml. Warns on no-op flags."""
500
- flag_map = _FLAG_KEY_MAP.get(job_type, {})
501
- overrides = {}
502
-
503
- for flag_attr, flag_label in [("model", "--model"), ("epochs", "--epochs"), ("batch_size", "--batch-size")]:
504
- value = getattr(args, flag_attr, None)
505
- if value is None:
506
- continue
507
- config_key = flag_map.get(flag_attr)
508
- if config_key is None:
509
- print(f" ⚠️ {flag_label} has no applicable config key for type '{job_type}' — skipped.")
510
- else:
511
- overrides[config_key] = value
512
-
513
- if overrides:
514
- if not _YAML_AVAILABLE:
515
- print(" ⚠️ PyYAML not available — cannot apply flag overrides. Install with: pip install pyyaml")
516
- return
517
- _patch_config(dest, job_type, overrides)
518
- for k, v in overrides.items():
519
- print(f" ✏️ config.yaml: {k} = {v}")
520
-
521
-
522
- def _print_next_steps(dest, job_name):
523
- """Read the workflow: block from config.yaml and print actionable next-step hints."""
524
- workflow = {}
525
- if _YAML_AVAILABLE:
526
- config_path = dest / "input" / "config.yaml"
527
- if config_path.exists():
528
- try:
529
- with open(config_path) as f:
530
- config = yaml.safe_load(f) or {}
531
- workflow = config.get("workflow", {})
532
- except Exception:
533
- pass
534
-
535
- print(f"\n📋 Next steps for '{job_name}':")
536
- print(f" 1. Add your training data to {dest.relative_to(REPO_ROOT)}/input/data/")
537
- print(f" 2. Review {dest.relative_to(REPO_ROOT)}/input/config.yaml")
538
- if workflow.get("generate_start_sh", True):
539
- print(f" 3. Run: bash {dest.relative_to(REPO_ROOT)}/start.sh")
540
- print(f" or: python skills/train/train_cli.py run {dest.relative_to(REPO_ROOT)}")
541
- else:
542
- print(f" 3. Run: python skills/train/train_cli.py run {dest.relative_to(REPO_ROOT)}")
543
- if workflow.get("confirm_before_run", False):
544
- print(f" ℹ️ confirm_before_run is enabled — you will be prompted before training starts.")
545
- if workflow.get("auto_summarize", False):
546
- print(f" ℹ️ Training results will be summarised automatically after the run.")
547
- print()
548
-
549
-
550
- def _generate_start_sh(dest, job_type):
551
- """Write an executable start.sh into the scaffolded job directory."""
552
- start_sh = dest / "start.sh"
553
- content = f"""#!/usr/bin/env bash
554
- # start.sh — Generated by /train skill for job type: {job_type}
555
- # Run this script from the project root or from inside the job directory.
556
-
557
- set -euo pipefail
558
-
559
- SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"
560
- cd "$SCRIPT_DIR"
561
-
562
- echo "[start.sh] Preparing environment..."
563
-
564
- # Activate virtual environment if present
565
- if [ -f "venv/bin/activate" ]; then
566
- echo "[start.sh] Activating venv..."
567
- # shellcheck disable=SC1091
568
- source venv/bin/activate
569
- fi
570
-
571
- # Install dependencies
572
- if [ -f "requirements.txt" ]; then
573
- echo "[start.sh] Installing dependencies from requirements.txt..."
574
- pip install -q -r requirements.txt
575
- fi
576
-
577
- echo "[start.sh] Starting training ({job_type})..."
578
- python train.py
579
-
580
- echo "[start.sh] Training complete."
581
- """
582
- start_sh.write_text(content)
583
- start_sh.chmod(0o755)
584
-
585
-
586
- def cmd_new(args):
587
- # Interactive wizard mode
588
- if args.interactive:
589
- run_wizard(full=args.full)
590
- return
591
-
592
- # Positional (scriptable) mode — both type and job_name must be supplied
593
- job_type = args.type
594
- job_name = args.job_name
595
-
596
- if not job_type or not job_name:
597
- print("❌ Positional mode requires both <type> and <job-name>.")
598
- print(" Usage: train new <type> <job-name>")
599
- print(f" Types: {', '.join(VALID_TYPES)}")
600
- print(" Or use: train new --interactive")
601
- sys.exit(1)
602
-
603
- if job_type not in VALID_TYPES:
604
- print(f"❌ Invalid type '{job_type}'. Must be one of: {', '.join(VALID_TYPES)}")
605
- sys.exit(1)
606
-
607
- dest = _scaffold_job(job_type, job_name)
608
- print(f"✅ Scaffolded job '{dest.name}' from template '{job_type}' at {dest.relative_to(REPO_ROOT)}/")
609
-
610
- # Apply CLI flag overrides to config.yaml (T03)
611
- _apply_cli_flags(dest, job_type, args)
612
-
613
- # Generate start.sh (E01_S04_T01)
614
- _generate_start_sh(dest, job_type)
615
-
616
- # Print workflow next-steps (T04)
617
- _print_next_steps(dest, dest.name)
618
-
619
-
620
- def stream_subprocess(cmd, cwd, label):
621
- """Run a command, stream output line-by-line with a label prefix. Returns exit code."""
622
- proc = subprocess.Popen(
623
- cmd,
624
- cwd=str(cwd),
625
- stdout=subprocess.PIPE,
626
- stderr=subprocess.STDOUT,
627
- text=True,
628
- bufsize=1,
629
- )
630
- for line in proc.stdout:
631
- print(f" {line}", end="")
632
- proc.wait()
633
- return proc.returncode
634
-
635
-
636
- def _check_template_version_drift(job_dir):
637
- """Warn if the job's template_version is behind the current manifest version."""
638
- if not _YAML_AVAILABLE:
639
- return
640
- config_path = job_dir / "input" / "config.yaml"
641
- if not config_path.exists():
642
- return
643
- try:
644
- import json
645
- with open(config_path) as f:
646
- config = yaml.safe_load(f) or {}
647
- job_version = config.get("template_version")
648
- if not job_version:
649
- return
650
-
651
- manifest_path = REPO_ROOT / ".training" / "template" / "manifest.json"
652
- if not manifest_path.exists():
653
- return
654
- with open(manifest_path) as f:
655
- manifest = json.load(f)
656
-
657
- # Detect job type from parent template dir match or by searching all types
658
- current_version = None
659
- for ttype, tdata in manifest.get("templates", {}).items():
660
- # Match by checking if the job was scaffolded from this type (best-effort: check model keys)
661
- current_version = tdata.get("version")
662
- break # Use first match; jobs carry their own template_version for comparison
663
-
664
- if not current_version:
665
- return
666
-
667
- def _parse_semver(v):
668
- try:
669
- return tuple(int(x) for x in str(v).split("."))
670
- except Exception:
671
- return (0, 0, 0)
672
-
673
- if _parse_semver(job_version) < _parse_semver(current_version):
674
- print(
675
- f"⚠ Template version mismatch: job uses {job_version}, "
676
- f"current template is {current_version}. "
677
- f"Consider re-scaffolding or manually updating assets."
678
- )
679
- except Exception:
680
- pass # drift check is non-blocking; never fail the run
681
-
682
-
683
- def cmd_run(args):
684
- job_dir = Path(args.job_dir)
685
- if not job_dir.is_absolute():
686
- job_dir = Path.cwd() / job_dir
687
- job_dir = job_dir.resolve()
688
-
689
- if not job_dir.exists():
690
- print(f"❌ Job directory not found: {job_dir}")
691
- sys.exit(1)
692
-
693
- _check_template_version_drift(job_dir)
694
-
695
- validate_script = job_dir / "validate.py"
696
- train_script = job_dir / "train.py"
697
-
698
- if not validate_script.exists():
699
- print(f"❌ validate.py not found in {job_dir}")
700
- sys.exit(1)
701
- if not train_script.exists():
702
- print(f"❌ train.py not found in {job_dir}")
703
- sys.exit(1)
704
-
705
- # Phase A — Pre-flight
706
- print(f"\n[pre-flight] Running validate.py in {job_dir}...")
707
- rc_a = stream_subprocess([sys.executable, "validate.py"], job_dir, "[pre-flight]")
708
- if rc_a != 0:
709
- print(f"❌ [pre-flight] FAILED — halting before smoke test.")
710
- sys.exit(rc_a)
711
- print("✅ [pre-flight] Passed.")
712
-
713
- # Phase B — Smoke test
714
- print(f"\n[smoke test] Running train.py --smoke in {job_dir}...")
715
- rc_b = stream_subprocess([sys.executable, "train.py", "--smoke"], job_dir, "[smoke test]")
716
- if rc_b != 0:
717
- print("❌ [smoke test] FAILED.")
718
- sys.exit(rc_b)
719
- print(f"✅ [smoke test] Passed. Job '{job_dir.name}' completed successfully.")
720
-
721
-
722
- def main():
723
- parser = argparse.ArgumentParser(
724
- description="ML training job orchestrator — scaffold and run jobs."
725
- )
726
- subparsers = parser.add_subparsers(dest="command", required=True)
727
-
728
- # new subcommand
729
- new_parser = subparsers.add_parser("new", help="Scaffold a new training job from a template")
730
- new_parser.add_argument(
731
- "type",
732
- nargs="?",
733
- choices=VALID_TYPES,
734
- default=None,
735
- help="Template type (required in positional mode)",
736
- )
737
- new_parser.add_argument(
738
- "job_name",
739
- nargs="?",
740
- default=None,
741
- help="Name for the new job directory (required in positional mode)",
742
- )
743
- new_parser.add_argument(
744
- "--interactive", "-i",
745
- action="store_true",
746
- help="Launch guided wizard to configure the job interactively",
747
- )
748
- new_parser.add_argument(
749
- "--full",
750
- action="store_true",
751
- help="Expand wizard to prompt for every configurable field (requires --interactive)",
752
- )
753
- new_parser.add_argument(
754
- "--model",
755
- default=None,
756
- metavar="VALUE",
757
- help="Override model name/type in config.yaml (e.g. bert-base-uncased, random_forest)",
758
- )
759
- new_parser.add_argument(
760
- "--epochs",
761
- default=None,
762
- metavar="N",
763
- help="Override training epochs / iterations in config.yaml",
764
- )
765
- new_parser.add_argument(
766
- "--batch-size",
767
- dest="batch_size",
768
- default=None,
769
- metavar="N",
770
- help="Override batch size in config.yaml",
771
- )
772
-
773
- # run subcommand
774
- run_parser = subparsers.add_parser("run", help="Run validate -> train pipeline on a job directory")
775
- run_parser.add_argument("job_dir", help="Path to the job directory")
776
-
777
- args = parser.parse_args()
778
-
779
- if args.command == "new":
780
- cmd_new(args)
781
- elif args.command == "run":
782
- cmd_run(args)
783
-
784
-
785
- if __name__ == "__main__":
786
- main()