LiteBuild 0.3.2__tar.gz → 0.5__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 (27) hide show
  1. {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/build_engine.py +223 -54
  2. {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/build_logger.py +1 -1
  3. {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/build_workers.py +95 -27
  4. {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/command_generator.py +113 -73
  5. {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/dependency_graph.py +8 -1
  6. {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/lite_build_controller.py +15 -2
  7. {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/lite_build_runner.py +159 -48
  8. {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/schema.py +29 -16
  9. litebuild-0.5/LiteBuild/sleep_inhibitor.py +57 -0
  10. litebuild-0.5/LiteBuild.egg-info/PKG-INFO +139 -0
  11. {litebuild-0.3.2 → litebuild-0.5}/LiteBuild.egg-info/SOURCES.txt +1 -0
  12. litebuild-0.5/LiteBuild.egg-info/requires.txt +5 -0
  13. litebuild-0.5/PKG-INFO +139 -0
  14. litebuild-0.5/docs/readme.md +112 -0
  15. {litebuild-0.3.2 → litebuild-0.5}/pyproject.toml +5 -14
  16. litebuild-0.3.2/LiteBuild.egg-info/PKG-INFO +0 -116
  17. litebuild-0.3.2/LiteBuild.egg-info/requires.txt +0 -9
  18. litebuild-0.3.2/PKG-INFO +0 -116
  19. litebuild-0.3.2/docs/readme.md +0 -81
  20. {litebuild-0.3.2 → litebuild-0.5}/LICENSE +0 -0
  21. {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/config_loader.py +0 -0
  22. {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/litebuild.py +0 -0
  23. {litebuild-0.3.2 → litebuild-0.5}/LiteBuild.egg-info/dependency_links.txt +0 -0
  24. {litebuild-0.3.2 → litebuild-0.5}/LiteBuild.egg-info/entry_points.txt +0 -0
  25. {litebuild-0.3.2 → litebuild-0.5}/LiteBuild.egg-info/top_level.txt +0 -0
  26. {litebuild-0.3.2 → litebuild-0.5}/setup.cfg +0 -0
  27. {litebuild-0.3.2 → litebuild-0.5}/tests/test_build_engine.py +0 -0
@@ -1,11 +1,13 @@
1
- # build_engine.py
2
1
  from concurrent.futures import ProcessPoolExecutor
2
+ import datetime
3
+ import difflib
3
4
  from enum import IntEnum
4
5
  import json
5
6
  import os
6
7
  from pathlib import Path
7
8
  import subprocess
8
9
  import time
10
+ import traceback
9
11
  from typing import List, Dict, Tuple, NamedTuple, Optional
10
12
 
11
13
  import networkx as nx
@@ -18,7 +20,7 @@ from LiteBuild.schema import BUILD_SCHEMA, LiteBuildValidator
18
20
 
19
21
 
20
22
  class UpdateCode(IntEnum):
21
- """Enumeration for why a build step is considered outdated."""
23
+ """Enumeration for why a build step is outdated."""
22
24
  UP_TO_DATE = 0
23
25
  MISSING_OUTPUT = 1
24
26
  NOT_TRACKED = 2
@@ -28,6 +30,7 @@ class UpdateCode(IntEnum):
28
30
  NEWER_INPUT = 6
29
31
  MISSING_INPUT = 7
30
32
  STALE_TARGET = 8
33
+ FORCED = 9
31
34
 
32
35
 
33
36
  class BuildStep(NamedTuple):
@@ -61,6 +64,20 @@ class BuildEngine:
61
64
 
62
65
  self.config = config_data
63
66
  self.state_file = state_file
67
+ # Validate Input Directory
68
+ input_dir = config_data.get("GENERAL", {}).get("INPUT_DIRECTORY")
69
+ if input_dir:
70
+ path = Path(input_dir)
71
+ if not path.exists():
72
+ raise FileNotFoundError(
73
+ f"❌ Configuration Error: INPUT_DIRECTORY does not exist.\n"
74
+ f" Path: {path.absolute()}"
75
+ )
76
+ if not path.is_dir():
77
+ raise NotADirectoryError(
78
+ f"❌ Configuration Error: INPUT_DIRECTORY is not a directory.\n"
79
+ f" Path: {path.absolute()}"
80
+ )
64
81
 
65
82
  @classmethod
66
83
  def from_file(
@@ -78,43 +95,73 @@ class BuildEngine:
78
95
  # Re-raise to be handled by the calling script (CLI or GUI)
79
96
  raise e
80
97
 
81
- def execute(self, final_step_name: str, profile_name: str = "", logger: BuildLogger = None):
98
+ def execute(self, final_step_name: str, profile_name: str = "", logger: BuildLogger = None, status_callback=None, force_rebuild=False):
82
99
  """
83
100
  Plans and executes the build for a specific workflow entry step.
84
-
85
- Args:
86
- final_step_name: The required step of the workflow DAG to execute.
87
- profile_name: An optional parameter context .
88
- logger: The logger instance to use for output.
89
101
  """
90
102
  if logger is None:
91
103
  logger = get_logger()
92
104
  setup_logger(logger)
93
105
 
94
- logger.log(f"🔵 Executing build for step {final_step_name} using: '{profile_name}'")
106
+ # --- Extract Context info ---
107
+ general_cfg = self.config.get("GENERAL", {})
108
+ profile_cfg = self.config.get("PROFILES", {}).get(profile_name, {})
109
+
110
+ # Get (optional) segment/category from General or Profile
111
+ segment = general_cfg.get("SEGMENT") or profile_cfg.get("SEGMENT") or ""
112
+ category = general_cfg.get("CATEGORY") or profile_cfg.get("CATEGORY") or ""
113
+
114
+ context_str = f"Segment: {segment} Category: {category} "
115
+ if profile_name:
116
+ context_str += f" Profile: {profile_name}"
117
+
118
+ # Get the current date and time
119
+ #now = datetime.now()
120
+
121
+ # Format the time as a string (HH:MM:SS) using strftime()
122
+ current_time = "" #now.strftime("%H:%M:%S")
123
+
124
+ logger.log(f"🔵 Executing build for step {final_step_name} - {current_time}")
125
+ logger.log(f"ℹ️ {context_str}\n")
95
126
 
96
127
  try:
97
128
  state_manager = BuildStateManager(self.state_file)
98
129
  planner = BuildPlanner(self.config, state_manager.load_state())
99
- plan = planner.plan_build(profile_name, final_step_name)
130
+ plan = planner.plan_build(profile_name, final_step_name, force_rebuild=force_rebuild)
100
131
 
101
132
  executor = BuildExecutor(state_manager, self.config)
102
- success = executor.execute_plan(plan, logger)
133
+ success = executor.execute_plan(plan, logger, status_callback=status_callback)
103
134
  except Exception as e:
104
135
  success = False
136
+ # --- ERROR REPORTING ---
137
+ #logger.log(f"\n❌ ERROR")
105
138
  logger.log(f"{e}")
139
+ #logger.log("\n--- Traceback ---")
140
+ #logger.log(traceback.format_exc())
141
+
142
+ # Update status callback if present
143
+ if status_callback:
144
+ status_callback("step", 0, 0, "error")
145
+ # Get the current date and time
146
+ #now = datetime.now()
147
+
148
+ # Format the time as a string (HH:MM:SS) using strftime()
149
+ current_time = "" #now.strftime("%H:%M:%S")
106
150
 
107
151
  if success:
108
- logger.log(f"\n✅ Build finished successfully.")
152
+ logger.log(f"\n✅ Build finished successfully. {current_time}")
109
153
  else:
110
- logger.log(f"❌ Build failed for {final_step_name} using: '{profile_name}'")
154
+ logger.log(f"🔴Build failed for {final_step_name}. {current_time}")
155
+
156
+ def has_profile(self, profile_name: str) -> bool:
157
+ """Checks if a specific profile key exists in the YAML config."""
158
+ return profile_name in self.config.get('PROFILES', {})
111
159
 
112
160
  def describe(self, profile_name: str) -> str:
113
161
  """Generates a Markdown description of the workflow for a given profile."""
114
162
  reporter = BuildReporter(self.config)
115
163
  return reporter.describe_workflow(profile_name)
116
164
 
117
-
118
165
  class BuildPlanner:
119
166
  """
120
167
  Analyzes the workflow and build state to create an incremental build plan.
@@ -125,10 +172,15 @@ class BuildPlanner:
125
172
  self.build_state = build_state
126
173
  self.logger = get_logger()
127
174
 
128
- def _is_step_outdated(self, command: Dict) -> Tuple[UpdateCode, str]:
175
+ def _is_step_outdated(self, command: Dict, force_rebuild: bool = False) -> Tuple[UpdateCode, str]:
176
+ # 1. Force Check (Short Circuit)
177
+ if force_rebuild:
178
+ self.logger.debug(f" - RESULT: Rebuild forced by user. (COMMAND_CHANGED)")
179
+ return UpdateCode.FORCED, ""
180
+
129
181
  """Checks a single step to see if it needs to be rebuilt, with detailed debug logging."""
130
182
  output_path = command['output']
131
- node_name = command.get('node_name', 'UnknownStep') # Assuming node_name is passed in command dict
183
+ node_name = command.get('node_name', 'UnknownStep')
132
184
 
133
185
  self.logger.debug(f"\n--- Checking status of step '{node_name}' ---")
134
186
  self.logger.debug(f" - Output file: '{output_path}'")
@@ -195,7 +247,7 @@ class BuildPlanner:
195
247
  self.logger.debug(f" - RESULT: Step is up-to-date. (UP_TO_DATE)")
196
248
  return UpdateCode.UP_TO_DATE, ""
197
249
 
198
- def plan_build(self, profile_name: str, final_step_name: str = None) -> BuildPlan:
250
+ def plan_build(self, profile_name: str, final_step_name: str = None, force_rebuild: bool = False) -> BuildPlan:
199
251
  command_map, execution_graph = self._generate_command_map_and_graph(
200
252
  profile_name, final_step_name
201
253
  )
@@ -207,7 +259,7 @@ class BuildPlanner:
207
259
  build_order = list(nx.topological_sort(execution_graph))
208
260
  for node_name in build_order:
209
261
  command = command_map[node_name]
210
- update_code, context = self._is_step_outdated(command)
262
+ update_code, context = self._is_step_outdated(command, force_rebuild=force_rebuild)
211
263
  if update_code != UpdateCode.UP_TO_DATE:
212
264
  initially_outdated[node_name] = (update_code, context)
213
265
 
@@ -227,6 +279,14 @@ class BuildPlanner:
227
279
  steps_to_skip.append(BuildStep(node_name, step_command, UpdateCode.UP_TO_DATE, ""))
228
280
  return BuildPlan(steps_to_run, steps_to_skip, command_map, execution_graph)
229
281
 
282
+ @staticmethod
283
+ def get_suggestion(invalid_key: str, valid_options: list[str]) -> str:
284
+ """Returns a 'Did you mean X?' string if a close match is found."""
285
+ matches = difflib.get_close_matches(invalid_key, valid_options, n=1, cutoff=0.6)
286
+ if matches:
287
+ return f"\n Did you mean '{matches[0]}'?"
288
+ return ""
289
+
230
290
  def _generate_command_map_and_graph(self, profile_name: str, final_step_name: str = None) -> \
231
291
  Tuple[Dict, nx.DiGraph]:
232
292
  """Generates all commands and the dependency graph for a given profile."""
@@ -237,8 +297,10 @@ class BuildPlanner:
237
297
  profile_config = all_profiles[profile_name]
238
298
  else:
239
299
  available = "\n - ".join(all_profiles.keys())
300
+ hint = self.get_suggestion(profile_name, list(all_profiles.keys()))
240
301
  raise ValueError(
241
- f"profile '{profile_name}' not found. Available profiles are:\n - {available}"
302
+ f"\nProfile '{profile_name}' not found.{hint}\n"
303
+ f"\nAvailable profiles:\n - {available}\n"
242
304
  )
243
305
 
244
306
  graph_manager = DependencyGraph(self.config.get("WORKFLOW", {}))
@@ -252,18 +314,23 @@ class BuildPlanner:
252
314
  input_basenames = context.get("INPUT_FILES")
253
315
 
254
316
  if input_dir and input_basenames:
255
- # Create the list of full paths
256
317
  full_paths = [os.path.join(input_dir, f) for f in input_basenames]
257
- # Overwrite the INPUT_FILES in the context with the full paths.
258
- # This makes the fully resolved list available to all template substitutions.
259
318
  context['INPUT_FILES'] = full_paths
260
319
 
261
320
  command_map, resolved_outputs = {}, {}
262
321
  for node_name in nx.topological_sort(execution_graph):
263
322
  node_data = execution_graph.nodes[node_name]
264
- command_map[node_name] = command_gen.generate_for_node(
265
- node_name, node_data, context, resolved_outputs
266
- )
323
+
324
+ # --- IMPROVED ERROR HANDLING ---
325
+ try:
326
+ command_map[node_name] = command_gen.generate_for_node(
327
+ node_name, node_data, context, resolved_outputs
328
+ )
329
+ except Exception as e:
330
+ # This catches errors in CommandGenerator (like invalid format strings)
331
+ # and adds the context of WHICH rule failed.
332
+ raise RuntimeError(f"Error generating COMMAND for rule '{node_name}': {e}") from e
333
+
267
334
  return command_map, execution_graph
268
335
 
269
336
  class BuildExecutor:
@@ -274,33 +341,43 @@ class BuildExecutor:
274
341
  self.build_state = state_manager.load_state()
275
342
  self.config = config
276
343
  self.update_codes = {
277
- UpdateCode.UP_TO_DATE: "(Up-to-date)", UpdateCode.MISSING_OUTPUT: "(Creating Output)",
278
- UpdateCode.NOT_TRACKED: "(First build)",
279
- UpdateCode.COMMAND_CHANGED: "(Command has changed)",
280
- UpdateCode.INPUTS_CHANGED: "(Input file list has changed)",
281
- UpdateCode.PARAMS_CHANGED: "(Parameters have changed)",
282
- UpdateCode.NEWER_INPUT: "(Input '{context}' is newer)",
283
- UpdateCode.MISSING_INPUT: "(Input '{context}' is missing)",
284
- UpdateCode.STALE_TARGET: "(Target is stale)"
344
+ UpdateCode.UP_TO_DATE: "", UpdateCode.MISSING_OUTPUT: "(Creating Output)",
345
+ UpdateCode.NOT_TRACKED: "(first build)",
346
+ UpdateCode.COMMAND_CHANGED: "(command has changed)",
347
+ UpdateCode.INPUTS_CHANGED: "(input file list has changed)",
348
+ UpdateCode.PARAMS_CHANGED: "(parameters have changed)",
349
+ UpdateCode.NEWER_INPUT: "(input '{context}' is newer)",
350
+ UpdateCode.MISSING_INPUT: "(input '{context}' is missing)",
351
+ UpdateCode.STALE_TARGET: "(stale target)",
352
+ UpdateCode.FORCED: "(forced)"
285
353
  }
286
354
 
287
- def execute_plan(self, plan: BuildPlan, logger: BuildLogger) -> bool:
355
+ def execute_plan(self, plan: BuildPlan, logger: BuildLogger, status_callback=None) -> bool:
288
356
  """Executes the build plan, managing parallel execution and state."""
289
357
  total_to_run = len(plan.steps_to_run)
290
358
  finished_count = 0
291
359
 
360
+ # Track timing for reporting
361
+ step_timings = {}
362
+ build_start_time = time.time()
363
+
364
+ # --- Initial Status Update ---
365
+ if status_callback:
366
+ status_callback("step", 0, total_to_run, "started")
367
+
292
368
  for step in plan.steps_to_skip:
293
369
  logger.log(f"Skipping '{step.node_name}' (up-to-date)")
294
370
 
295
371
  if not plan.steps_to_run:
372
+ if status_callback:
373
+ status_callback("step", 0, 0, "done")
296
374
  return True
297
375
 
298
- # Ask the logger for the information needed to initialize workers.
299
- # This is polymorphic: a FileLogger provides info, a StreamLogger does not.
300
376
  worker_init_info = logger.get_worker_init_info()
301
377
  initializer, initargs = (worker_init_info if worker_init_info else (None, ()))
302
378
 
303
379
  tasks_to_run_map = {s.node_name: s for s in plan.steps_to_run}
380
+
304
381
  for generation in nx.topological_generations(plan.execution_graph):
305
382
  tasks_this_generation = []
306
383
  for node_name in generation:
@@ -323,62 +400,138 @@ class BuildExecutor:
323
400
  halt_build = False
324
401
  for status, result_data in results:
325
402
  step_name = result_data.get('step_name', 'N/A')
403
+
404
+ # Capture timing if available
405
+ if 'elapsed_time' in result_data:
406
+ step_timings[step_name] = result_data['elapsed_time']
407
+
326
408
  if status == 'EXECUTED':
327
409
  finished_count += 1
328
410
  logger.log(f"✅ Finished step '{step_name}' [{finished_count}/{total_to_run}]")
411
+ if status_callback:
412
+ status_callback("step", finished_count, total_to_run, "done")
329
413
  self.build_state[result_data['output_path']] = {
330
414
  "hashes": result_data['hashes'], "mtime": result_data['mtime']
331
415
  }
332
416
  elif status == 'FAILED':
333
417
  halt_build = True
334
- logger.log(f"❌ Build failed for Step '{step_name}'")
418
+ logger.log(f"🔺 Build failed for Step '{step_name}'")
419
+ if status_callback:
420
+ status_callback("step", finished_count, total_to_run, "error")
421
+
335
422
  if halt_build:
336
423
  self.state_manager.save_state(self.build_state)
337
424
  return False
425
+
338
426
  self.state_manager.save_state(self.build_state)
427
+
428
+ # --- TIMING REPORT ---
429
+ total_build_time = time.time() - build_start_time
430
+ self._print_timing_report(logger, step_timings, total_build_time)
431
+
339
432
  return True
340
433
 
341
434
  @staticmethod
342
435
  def _run_single_command(task: Tuple[str, Dict, str]) -> Tuple[str, Dict]:
343
- """Runs a command and streams all output to the configured log ."""
436
+ """Runs a command and streams all output to the configured log."""
344
437
  logger = get_logger()
345
438
  step_name, command, update_text = task
346
439
  output_path = command['output']
347
440
 
441
+ # --- Helper for log truncation ---
442
+ def _truncate(text: str, limit: int = 400) -> str:
443
+ """Keeps the start and end of long strings."""
444
+ if len(text) <= limit:
445
+ return text
446
+
447
+ # Keep first 40% and last 40% of the limit
448
+ keep = int(limit * 0.4)
449
+ omitted_count = len(text) - (keep * 2)
450
+
451
+ # Formatting: Clear brackets with internal spacing
452
+ return f"{text[:keep]} [ ... {omitted_count} chars truncated ... ] {text[-keep:]}"
453
+
454
+ # Log the command (Truncated)
455
+ cmd_display = _truncate(command['cmd_string'])
348
456
  logger.log(f"\n▶️ Running step '{step_name}': {update_text}")
349
- logger.log(f" [{step_name}] {command['cmd_string']}")
457
+ logger.log(f" [{step_name}] {cmd_display}")
458
+
459
+ start_time = time.perf_counter()
350
460
 
351
461
  try:
352
462
  process = subprocess.Popen(
353
463
  command['cmd_string'], shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
354
464
  text=True, encoding='utf-8', errors='replace'
355
465
  )
466
+
356
467
  for line in iter(process.stdout.readline, ''):
357
- logger.log(f" [{step_name}] {line.strip()}")
468
+ clean_line = line.strip()
469
+ if clean_line:
470
+ # Also truncate massive output lines (rare but possible with WKT echos)
471
+ logger.log(f" [{step_name}] {_truncate(clean_line)}")
472
+
358
473
  return_code = process.wait()
474
+
475
+ end_time = time.perf_counter()
476
+ elapsed = end_time - start_time
477
+
359
478
  if return_code != 0:
360
479
  raise subprocess.CalledProcessError(return_code, "")
361
480
 
362
481
  new_mtime = os.path.getmtime(output_path)
363
482
  result_data = {
364
- 'step_name': step_name, 'output_path': output_path, 'hashes': command['hashes'],
365
- 'mtime': new_mtime
483
+ 'step_name': step_name,
484
+ 'output_path': output_path,
485
+ 'hashes': command['hashes'],
486
+ 'mtime': new_mtime,
487
+ 'elapsed_time': elapsed
366
488
  }
367
489
  return 'EXECUTED', result_data
368
490
  except Exception as e:
369
- logger.log(f"❌ Step '{step_name}' failed: {e}")
491
+ logger.log(f"🔺 Step '{step_name}' failed: {e}")
370
492
  result_data = {'step_name': step_name}
371
493
  return 'FAILED', result_data
372
494
 
495
+ def _print_timing_report(self, logger: BuildLogger, step_timings: Dict[str, float], total_time: float):
496
+ """Generates and logs the timing summary table."""
497
+ logger.log("\n🔵 Timing Report:")
498
+
499
+ # Sort by duration (Longest first)
500
+ sorted_steps = sorted(step_timings.items(), key=lambda item: item[1], reverse=True)
501
+
502
+ # Calculate "CPU Time" (Sum of all work) vs "Wall Time" (Real world time)
503
+ total_cpu_time = sum(step_timings.values())
504
+
505
+ for step_name, duration in sorted_steps:
506
+ # Percent of the Wall Clock time this step was active
507
+ percent = (duration / total_time) * 100 if total_time > 0 else 0
508
+
509
+ minutes = int(duration // 60)
510
+ seconds = duration % 60
511
+ if minutes > 0:
512
+ time_str = f"{minutes}:{seconds:05.2f}"
513
+ else:
514
+ time_str = f"{seconds:.2f}s"
515
+
516
+ logger.log(f"{step_name:<30} {percent:>5.1f}% {time_str}")
517
+
518
+ t_min = int(total_time // 60)
519
+ t_sec = total_time % 60
520
+
521
+ # Calculate parallelism factor (e.g., 2.5x speedup)
522
+ speedup = total_cpu_time / total_time if total_time > 0 else 1.0
523
+
524
+ logger.log("-" * 50)
525
+ logger.log(f"Wall Time: {t_min}:{t_sec:05.2f} Parallel Speedup: {speedup:.1f}x")
373
526
 
374
527
  class BuildReporter:
375
- """Generates human-readable descriptions and diagrams of the workflow."""
528
+ """Generates human-readable description of the workflow."""
376
529
 
377
530
  def __init__(self, config: Dict):
378
531
  self.config = config
379
532
 
380
533
  def generate_mermaid_diagram(self, graph: nx.DiGraph) -> str:
381
- """Creates a Mermaid graph syntax string for the workflow."""
534
+ """Creates a Mermaid graph for the workflow."""
382
535
  if not graph.nodes:
383
536
  return "graph TD;\n Empty_Workflow[Workflow is empty];"
384
537
 
@@ -405,7 +558,7 @@ class BuildReporter:
405
558
  def describe_workflow(self, profile_name: str) -> str:
406
559
  """Generates a full Markdown report for the workflow."""
407
560
  import datetime
408
- timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
561
+ timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
409
562
  project_name = self.config.get("GENERAL", {}).get("PROJECT_NAME", "LiteBuild Project")
410
563
 
411
564
  # 1. Generate the Plan to resolve all variables
@@ -427,33 +580,50 @@ class BuildReporter:
427
580
  f"# {project_name} Pipeline Documentation",
428
581
  "",
429
582
  f"**Profile:** `{profile_name}` ",
430
- f"**Generated:** {timestamp} ",
583
+ f"**Date:** {timestamp} ",
431
584
  f"**Target Output:** `{final_output}`",
432
585
  "",
433
586
  "---",
434
587
  ""
435
588
  ]
436
589
 
590
+ # --- OVERVIEW SECTION ---
591
+ # Checks for the global OVERVIEW key in the config
592
+ overview_text = self.config.get("OVERVIEW")
593
+ if overview_text:
594
+ lines.append("## Overview")
595
+ lines.append(overview_text.strip())
596
+ lines.append("")
597
+ lines.append("---")
598
+ lines.append("")
599
+
437
600
  # --- MERMAID DIAGRAM ---
438
- lines.append("## Workflow Visualization")
601
+ lines.append("## Workflow ")
439
602
  lines.append("```mermaid")
440
603
  lines.append(self.generate_mermaid_diagram(graph))
441
604
  lines.append("```")
442
605
  lines.append("")
443
606
 
444
607
  # --- STEP DETAIL ---
445
- lines.append("## Step-by-Step Guide")
608
+ lines.append("## Detailed Steps")
446
609
 
447
610
  workflow_def = self.config.get("WORKFLOW", {})
448
611
 
449
612
  for node_name in build_order:
450
613
  cmd_data = plan.command_map[node_name]
451
614
  step_def = workflow_def.get(node_name, {})
452
-
453
- description = step_def.get("DESCRIPTION", f"Executes rule: `{step_def.get('RULE', {}).get('NAME')}`")
615
+ rule_name = step_def.get('RULE', {}).get('NAME')
454
616
 
455
617
  lines.append(f"### {node_name}")
456
- lines.append(f"_{description}_")
618
+
619
+ # --- DESCRIPTION LOGIC ---
620
+ if "DESCRIPTION" in step_def:
621
+ # Use blockquote for user-defined descriptions (handles multi-line well)
622
+ lines.append(f"> {step_def['DESCRIPTION']}")
623
+ else:
624
+ # Fallback to technical description
625
+ lines.append(f"_Executes rule: `{rule_name}`_")
626
+
457
627
  lines.append("")
458
628
 
459
629
  # Inputs
@@ -476,7 +646,6 @@ class BuildReporter:
476
646
 
477
647
  return "\n".join(lines)
478
648
 
479
-
480
649
  class BuildStateManager:
481
650
  """Manages loading and saving the .build_state.json file."""
482
651
 
@@ -505,4 +674,4 @@ class BuildStateManager:
505
674
  # --- Worker initializer accepts a logger object ---
506
675
  def setup_worker_logger(logger: BuildLogger):
507
676
  """Initializes the logger for a worker process."""
508
- setup_logger(logger)
677
+ setup_logger(logger)
@@ -1,7 +1,7 @@
1
1
  # build_logger.py
2
2
 
3
3
  from contextlib import nullcontext
4
- from enum import IntEnum # <-- ADDED
4
+ from enum import IntEnum
5
5
  from pathlib import Path
6
6
  import sys
7
7
  from typing import Optional, Tuple, Callable, Any, Union, TextIO