@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.
- package/README.md +28 -10
- package/agents/scrum-master.md +75 -0
- package/mcp/router/embedder.js +1 -1
- package/mcp/training_runner/index.js +239 -0
- package/mcp/training_runner/package-lock.json +1065 -0
- package/mcp/training_runner/package.json +15 -0
- package/package.json +13 -11
- package/skills/close-story/SKILL.md +203 -0
- package/skills/close-story/scripts/check-story-closeable.sh +195 -0
- package/skills/close-story/scripts/compute-scope-divergence.sh +128 -0
- package/skills/close-story/scripts/extract-diff-stats.sh +48 -0
- package/skills/close-story/scripts/extract-task-diff-stats.sh +97 -0
- package/skills/close-story/scripts/update-task-frontmatter.sh +103 -0
- package/skills/commit/SKILL.md +18 -0
- package/skills/distribute/CONFIG_SCHEMA.md +90 -0
- package/skills/distribute/SKILL.md +173 -0
- package/skills/distribute/scripts/check-version.sh +74 -0
- package/skills/distribute/scripts/commit-version-bump.sh +108 -0
- package/skills/distribute/scripts/distribute-changes.sh +381 -0
- package/skills/do/SKILL.md +314 -0
- package/skills/do/assets/intent-vs-diff-prompt.md +69 -0
- package/skills/doc/assets/path-objectives.yaml +13 -0
- package/skills/init/SKILL.md +4 -3
- package/skills/init/assets/strategy_stub_template.md +38 -0
- package/skills/init/scripts/init.sh +6 -1
- package/skills/jenga/SKILL.md +51 -2
- package/skills/strategy/SKILL.md +312 -0
- package/templates/SCRUM_BOARD_SCHEMA.md +49 -0
- package/skills/train/SKILL.md +0 -116
- package/skills/train/assets/dashboard-templates/classifiers.html +0 -106
- package/skills/train/assets/dashboard-templates/nlp.html +0 -102
- package/skills/train/assets/dashboard-templates/transformers.html +0 -98
- package/skills/train/assets/results-parsers/__init__.py +0 -9
- package/skills/train/assets/results-parsers/classifiers.py +0 -84
- package/skills/train/assets/results-parsers/nlp.py +0 -88
- package/skills/train/assets/results-parsers/reporter.py +0 -154
- package/skills/train/assets/results-parsers/transformers.py +0 -120
- 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()
|