loopflow 0.6.2__tar.gz → 0.6.3__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.
- {loopflow-0.6.2 → loopflow-0.6.3}/.gitignore +1 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/PKG-INFO +1 -1
- {loopflow-0.6.2 → loopflow-0.6.3}/pyproject.toml +1 -0
- loopflow-0.6.3/src/loopflow/__init__.py +1 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/cli/__init__.py +49 -16
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/cli/run.py +70 -19
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/context.py +77 -3
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/files.py +54 -19
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/launcher.py +17 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/collector.py +13 -8
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/pipelines.py +78 -82
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/runner.py +5 -1
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfops.py +36 -84
- loopflow-0.6.3/src/loopflow/pipeline.py +630 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/publish.py +92 -0
- loopflow-0.6.3/src/loopflow/templates/commands/expand.md +27 -0
- loopflow-0.6.3/src/loopflow/templates/commands/explore.md +27 -0
- loopflow-0.6.3/src/loopflow/templates/commands/reduce.md +27 -0
- loopflow-0.6.2/src/loopflow/__init__.py +0 -1
- loopflow-0.6.2/src/loopflow/pipeline.py +0 -143
- {loopflow-0.6.2 → loopflow-0.6.3}/README.md +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/LOOPFLOW.md +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/builtins/__init__.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/builtins/commit_message.txt +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/builtins/pr_message.txt +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/builtins/release_notes.txt +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/config.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/design.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/frontmatter.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/git.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/init_check.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/README.md +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/__init__.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/__main__.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/agent_runner.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/agents.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/client.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/cron.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/db.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/launchd.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/models.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/naming.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/process.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/protocol.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/server.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/triggers.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfwt.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/llm_http.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/logging.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/prompts/CHECKPOINT_MESSAGE.md +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/prompts/COMMIT_MESSAGE.md +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/templates/commands/debug.md +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/templates/commands/design.md +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/templates/commands/implement.md +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/templates/commands/iterate.md +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/templates/commands/polish.md +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/templates/commands/review.md +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/templates/config.yaml +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/tokens.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/voices.py +0 -0
- {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/worktrees.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loopflow
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
4
4
|
Summary: Run LLM coding agents from reusable prompt files
|
|
5
5
|
Project-URL: Homepage, https://loopflowstudio.github.io/loopflow/
|
|
6
6
|
Project-URL: Repository, https://github.com/loopflowstudio/loopflow
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.6.3"
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
"""Loopflow CLI: Arrange LLMs to code in harmony."""
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
|
+
from typing import Optional
|
|
4
5
|
|
|
5
6
|
import typer
|
|
6
7
|
|
|
7
8
|
from loopflow.config import ConfigError, load_config
|
|
8
|
-
from loopflow.context import find_worktree_root, gather_task
|
|
9
|
+
from loopflow.context import find_worktree_root, gather_task, list_all_tasks, _get_builtin_task
|
|
9
10
|
from loopflow.init_check import check_init_status
|
|
11
|
+
from loopflow.lfd.pipelines import load_pipeline
|
|
10
12
|
|
|
11
13
|
app = typer.Typer(
|
|
12
14
|
name="lf",
|
|
13
15
|
help="Arrange LLMs to code in harmony.",
|
|
14
|
-
no_args_is_help=
|
|
16
|
+
no_args_is_help=False,
|
|
15
17
|
)
|
|
16
18
|
|
|
17
19
|
# Import and register subcommands
|
|
@@ -24,6 +26,33 @@ app.command(name="pipeline")(run_module.pipeline)
|
|
|
24
26
|
app.command()(run_module.cp)
|
|
25
27
|
|
|
26
28
|
|
|
29
|
+
def _list_tasks() -> None:
|
|
30
|
+
"""List available tasks and pipelines."""
|
|
31
|
+
repo_root = find_worktree_root()
|
|
32
|
+
config = load_config(repo_root) if repo_root else None
|
|
33
|
+
|
|
34
|
+
user_tasks, builtin_only = list_all_tasks(repo_root)
|
|
35
|
+
pipelines = list(config.pipelines.keys()) if config else []
|
|
36
|
+
|
|
37
|
+
# Show pipelines
|
|
38
|
+
if pipelines:
|
|
39
|
+
typer.echo("Pipelines:")
|
|
40
|
+
for name in sorted(pipelines):
|
|
41
|
+
typer.echo(f" {name}")
|
|
42
|
+
typer.echo()
|
|
43
|
+
|
|
44
|
+
# Show tasks
|
|
45
|
+
if user_tasks or builtin_only:
|
|
46
|
+
typer.echo("Tasks:")
|
|
47
|
+
for name in user_tasks:
|
|
48
|
+
typer.echo(f" {name}")
|
|
49
|
+
for name in builtin_only:
|
|
50
|
+
typer.echo(f" {name} (builtin)")
|
|
51
|
+
else:
|
|
52
|
+
typer.echo("No tasks found.")
|
|
53
|
+
typer.echo("Run: lfops init")
|
|
54
|
+
|
|
55
|
+
|
|
27
56
|
def main():
|
|
28
57
|
"""Entry point that supports 'lf <task>' and 'lf <pipeline>' shorthand."""
|
|
29
58
|
known_commands = {
|
|
@@ -36,6 +65,11 @@ def main():
|
|
|
36
65
|
}
|
|
37
66
|
|
|
38
67
|
try:
|
|
68
|
+
# Handle 'lf' with no arguments: list available tasks
|
|
69
|
+
if len(sys.argv) == 1:
|
|
70
|
+
_list_tasks()
|
|
71
|
+
raise SystemExit(0)
|
|
72
|
+
|
|
39
73
|
if len(sys.argv) > 1:
|
|
40
74
|
first_arg = sys.argv[1]
|
|
41
75
|
|
|
@@ -51,8 +85,16 @@ def main():
|
|
|
51
85
|
repo_root = find_worktree_root()
|
|
52
86
|
config = load_config(repo_root) if repo_root else None
|
|
53
87
|
|
|
54
|
-
|
|
55
|
-
|
|
88
|
+
# Check for pipeline in config.yaml or .lf/pipelines/
|
|
89
|
+
has_config_pipeline = config and name in config.pipelines
|
|
90
|
+
has_file_pipeline = repo_root and load_pipeline(name, repo_root) is not None
|
|
91
|
+
has_pipeline = has_config_pipeline or has_file_pipeline
|
|
92
|
+
|
|
93
|
+
# gather_task now includes builtins
|
|
94
|
+
has_task = gather_task(repo_root, name) is not None if repo_root else False
|
|
95
|
+
# Also check builtin even without repo_root
|
|
96
|
+
if not has_task and not repo_root:
|
|
97
|
+
has_task = _get_builtin_task(name) is not None
|
|
56
98
|
|
|
57
99
|
if has_pipeline and has_task:
|
|
58
100
|
typer.echo(f"Error: '{name}' exists as both a pipeline and a task", err=True)
|
|
@@ -66,18 +108,9 @@ def main():
|
|
|
66
108
|
elif has_task:
|
|
67
109
|
sys.argv.insert(1, "run")
|
|
68
110
|
else:
|
|
69
|
-
#
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
# Uninitialized repo - suggest init
|
|
73
|
-
typer.echo(f"No task named '{name}' found.", err=True)
|
|
74
|
-
typer.echo("", err=True)
|
|
75
|
-
typer.echo("This repo hasn't been set up for loopflow yet.", err=True)
|
|
76
|
-
typer.echo("Run: lfops init", err=True)
|
|
77
|
-
else:
|
|
78
|
-
# Initialized but task missing - suggest creating it
|
|
79
|
-
typer.echo(f"No task or pipeline named '{name}'", err=True)
|
|
80
|
-
typer.echo(f"Create: .claude/commands/{name}.md", err=True)
|
|
111
|
+
# Task not found
|
|
112
|
+
typer.echo(f"No task or pipeline named '{name}'", err=True)
|
|
113
|
+
typer.echo(f"Run 'lf' to see available tasks.", err=True)
|
|
81
114
|
raise SystemExit(1)
|
|
82
115
|
|
|
83
116
|
app()
|
|
@@ -23,7 +23,8 @@ from loopflow.launcher import (
|
|
|
23
23
|
from loopflow.logging import get_model_env, write_prompt_file
|
|
24
24
|
from loopflow.lfd.client import log_session_start, log_session_end
|
|
25
25
|
from loopflow.lfd.models import Session, SessionStatus
|
|
26
|
-
from loopflow.
|
|
26
|
+
from loopflow.lfd.pipelines import load_pipeline as load_pipeline_file, PipelineDef, PipelineStep, RaceConfig
|
|
27
|
+
from loopflow.pipeline import run_pipeline_def, _run_race_step
|
|
27
28
|
from loopflow.tokens import analyze_components
|
|
28
29
|
from loopflow.worktrees import WorktreeError, create
|
|
29
30
|
|
|
@@ -78,6 +79,7 @@ def _execute_task(
|
|
|
78
79
|
model_variant=model_variant,
|
|
79
80
|
sandbox_root=repo_root.parent,
|
|
80
81
|
workdir=repo_root,
|
|
82
|
+
images=components.image_files,
|
|
81
83
|
)
|
|
82
84
|
else:
|
|
83
85
|
command = build_model_command(
|
|
@@ -88,6 +90,7 @@ def _execute_task(
|
|
|
88
90
|
model_variant=model_variant,
|
|
89
91
|
sandbox_root=repo_root.parent,
|
|
90
92
|
workdir=repo_root,
|
|
93
|
+
images=components.image_files,
|
|
91
94
|
)
|
|
92
95
|
|
|
93
96
|
# For interactive mode, run CLI directly to preserve terminal
|
|
@@ -188,7 +191,10 @@ def run(
|
|
|
188
191
|
None, "--voice", help="Voice(s) to use (comma-separated, e.g., 'architect,concise')"
|
|
189
192
|
),
|
|
190
193
|
parallel: str = typer.Option(
|
|
191
|
-
None, "--parallel", help="Run in parallel with multiple models (e.g., 'claude,codex')"
|
|
194
|
+
None, "--parallel", help="Run in parallel with multiple models, keep worktrees (e.g., 'claude,codex')"
|
|
195
|
+
),
|
|
196
|
+
race: str = typer.Option(
|
|
197
|
+
None, "--race", help="Race multiple models, auto-judge winner (e.g., 'claude,codex,gemini')"
|
|
192
198
|
),
|
|
193
199
|
):
|
|
194
200
|
"""Run a task with an LLM model."""
|
|
@@ -197,7 +203,29 @@ def run(
|
|
|
197
203
|
typer.echo("Error: Not in a git repository", err=True)
|
|
198
204
|
raise typer.Exit(1)
|
|
199
205
|
|
|
200
|
-
# Handle
|
|
206
|
+
# Handle race execution
|
|
207
|
+
if race:
|
|
208
|
+
models = [m.strip() for m in race.split(",")]
|
|
209
|
+
config = load_config(repo_root)
|
|
210
|
+
main_repo = find_main_repo(repo_root) or repo_root
|
|
211
|
+
skip_permissions = config.yolo if config else False
|
|
212
|
+
exclude = list(config.exclude) if config and config.exclude else None
|
|
213
|
+
|
|
214
|
+
race_config = RaceConfig(models=models)
|
|
215
|
+
result_code = _run_race_step(
|
|
216
|
+
task,
|
|
217
|
+
race_config,
|
|
218
|
+
repo_root,
|
|
219
|
+
main_repo,
|
|
220
|
+
exclude,
|
|
221
|
+
skip_permissions,
|
|
222
|
+
list(context) if context else None,
|
|
223
|
+
1, # step_num
|
|
224
|
+
1, # total_steps
|
|
225
|
+
)
|
|
226
|
+
raise typer.Exit(result_code)
|
|
227
|
+
|
|
228
|
+
# Handle parallel execution (creates persistent worktrees)
|
|
201
229
|
if parallel:
|
|
202
230
|
models = [m.strip() for m in parallel.split(",")]
|
|
203
231
|
for model_name in models:
|
|
@@ -518,7 +546,7 @@ def cp(
|
|
|
518
546
|
|
|
519
547
|
|
|
520
548
|
def pipeline(
|
|
521
|
-
name: str = typer.Argument(help="Pipeline name from config.yaml"),
|
|
549
|
+
name: str = typer.Argument(help="Pipeline name from config.yaml or .lf/pipelines/"),
|
|
522
550
|
context: list[str] = typer.Option(
|
|
523
551
|
None, "-x", "--context", help="Context files for all tasks"
|
|
524
552
|
),
|
|
@@ -542,11 +570,16 @@ def pipeline(
|
|
|
542
570
|
raise typer.Exit(1)
|
|
543
571
|
|
|
544
572
|
config = load_config(repo_root)
|
|
545
|
-
|
|
546
|
-
|
|
573
|
+
|
|
574
|
+
# Check for pipeline in .lf/pipelines/ first, then config.yaml
|
|
575
|
+
pipeline_def = load_pipeline_file(name, repo_root)
|
|
576
|
+
config_pipeline = config.pipelines.get(name) if config else None
|
|
577
|
+
|
|
578
|
+
if not pipeline_def and not config_pipeline:
|
|
579
|
+
typer.echo(f"Error: Pipeline '{name}' not found in .lf/pipelines/ or .lf/config.yaml", err=True)
|
|
547
580
|
raise typer.Exit(1)
|
|
548
581
|
|
|
549
|
-
agent_model = model or config.agent_model
|
|
582
|
+
agent_model = model or (config.agent_model if config else "claude:opus")
|
|
550
583
|
backend, model_variant = parse_model(agent_model)
|
|
551
584
|
|
|
552
585
|
try:
|
|
@@ -567,24 +600,32 @@ def pipeline(
|
|
|
567
600
|
raise typer.Exit(1)
|
|
568
601
|
repo_root = worktree_path
|
|
569
602
|
|
|
570
|
-
all_context = list(config.context) if config.context else []
|
|
603
|
+
all_context = list(config.context) if config and config.context else []
|
|
571
604
|
if context:
|
|
572
605
|
all_context.extend(context)
|
|
573
606
|
|
|
574
|
-
exclude = list(config.exclude) if config.exclude else None
|
|
607
|
+
exclude = list(config.exclude) if config and config.exclude else None
|
|
575
608
|
|
|
576
609
|
if copy:
|
|
577
610
|
# Show tokens for first task in pipeline
|
|
578
|
-
|
|
611
|
+
if pipeline_def:
|
|
612
|
+
first_task = pipeline_def.steps[0].task if pipeline_def.steps else None
|
|
613
|
+
else:
|
|
614
|
+
first_task = config_pipeline.tasks[0] if config_pipeline.tasks else None
|
|
615
|
+
|
|
616
|
+
if not first_task:
|
|
617
|
+
typer.echo("Error: Pipeline has no tasks", err=True)
|
|
618
|
+
raise typer.Exit(1)
|
|
619
|
+
|
|
579
620
|
components = gather_prompt_components(
|
|
580
621
|
repo_root,
|
|
581
622
|
first_task,
|
|
582
623
|
context=all_context or None,
|
|
583
624
|
exclude=exclude,
|
|
584
625
|
include_tests_for=config.include_tests_for if config else None,
|
|
585
|
-
include_loopflow_doc=config.include_loopflow_doc,
|
|
586
|
-
include_diff=config.diff,
|
|
587
|
-
include_diff_files=config.diff_files,
|
|
626
|
+
include_loopflow_doc=config.include_loopflow_doc if config else True,
|
|
627
|
+
include_diff=config.diff if config else False,
|
|
628
|
+
include_diff_files=config.diff_files if config else True,
|
|
588
629
|
)
|
|
589
630
|
prompt = format_prompt(components)
|
|
590
631
|
_copy_to_clipboard(prompt)
|
|
@@ -594,16 +635,26 @@ def pipeline(
|
|
|
594
635
|
typer.echo("\nCopied to clipboard.")
|
|
595
636
|
raise typer.Exit(0)
|
|
596
637
|
|
|
597
|
-
push_enabled = config.push
|
|
598
|
-
pr_enabled = pr if pr is not None else config.pr
|
|
638
|
+
push_enabled = config.push if config else False
|
|
639
|
+
pr_enabled = pr if pr is not None else (config.pr if config else False)
|
|
640
|
+
skip_permissions = config.yolo if config else False
|
|
641
|
+
|
|
642
|
+
# Convert config.yaml pipeline to PipelineDef if needed
|
|
643
|
+
if not pipeline_def:
|
|
644
|
+
# PipelineConfig.push/pr override global settings
|
|
645
|
+
push_enabled = config_pipeline.push if config_pipeline.push is not None else push_enabled
|
|
646
|
+
pr_enabled = config_pipeline.pr if config_pipeline.pr is not None else pr_enabled
|
|
647
|
+
pipeline_def = PipelineDef(
|
|
648
|
+
name=name,
|
|
649
|
+
steps=[PipelineStep(task=t) for t in config_pipeline.tasks],
|
|
650
|
+
)
|
|
599
651
|
|
|
600
|
-
exit_code =
|
|
601
|
-
|
|
652
|
+
exit_code = run_pipeline_def(
|
|
653
|
+
pipeline_def,
|
|
602
654
|
repo_root,
|
|
603
655
|
context=all_context or None,
|
|
604
656
|
exclude=exclude,
|
|
605
|
-
|
|
606
|
-
skip_permissions=config.yolo,
|
|
657
|
+
skip_permissions=skip_permissions,
|
|
607
658
|
push_enabled=push_enabled,
|
|
608
659
|
pr_enabled=pr_enabled,
|
|
609
660
|
backend=backend,
|
|
@@ -7,11 +7,15 @@ from pathlib import Path
|
|
|
7
7
|
from typing import Optional
|
|
8
8
|
|
|
9
9
|
from loopflow.design import gather_design_docs
|
|
10
|
-
from loopflow.files import gather_docs, gather_files, format_files
|
|
10
|
+
from loopflow.files import gather_docs, gather_files, format_files, format_image_references
|
|
11
11
|
from loopflow.frontmatter import TaskFile, parse_task_file
|
|
12
12
|
from loopflow.voices import Voice, load_voice
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
# Path to bundled builtin templates
|
|
16
|
+
_TEMPLATES_DIR = Path(__file__).parent / "templates" / "commands"
|
|
17
|
+
|
|
18
|
+
|
|
15
19
|
@dataclass
|
|
16
20
|
class PromptComponents:
|
|
17
21
|
"""Raw components of a prompt before assembly."""
|
|
@@ -25,6 +29,7 @@ class PromptComponents:
|
|
|
25
29
|
clipboard: str | None = None
|
|
26
30
|
loopflow_doc: str | None = None # Bundled system documentation
|
|
27
31
|
voices: list[Voice] | None = None
|
|
32
|
+
image_files: list[Path] | None = None # Images for visual context
|
|
28
33
|
|
|
29
34
|
|
|
30
35
|
def find_worktree_root(start: Optional[Path] = None) -> Path | None:
|
|
@@ -65,6 +70,63 @@ def _read_clipboard() -> str | None:
|
|
|
65
70
|
return None
|
|
66
71
|
|
|
67
72
|
|
|
73
|
+
def _get_builtin_task(name: str) -> Path | None:
|
|
74
|
+
"""Return path to bundled template if it exists."""
|
|
75
|
+
builtin = _TEMPLATES_DIR / f"{name}.md"
|
|
76
|
+
return builtin if builtin.exists() else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def list_builtin_tasks() -> list[str]:
|
|
80
|
+
"""Return names of all builtin tasks."""
|
|
81
|
+
if not _TEMPLATES_DIR.exists():
|
|
82
|
+
return []
|
|
83
|
+
return sorted(p.stem for p in _TEMPLATES_DIR.glob("*.md"))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def list_user_tasks(repo_root: Path) -> list[str]:
|
|
87
|
+
"""Return names of user-defined tasks in the repo."""
|
|
88
|
+
tasks = set()
|
|
89
|
+
|
|
90
|
+
# .claude/commands/*.md
|
|
91
|
+
claude_dir = repo_root / ".claude" / "commands"
|
|
92
|
+
if claude_dir.exists():
|
|
93
|
+
for p in claude_dir.glob("*.md"):
|
|
94
|
+
tasks.add(p.stem)
|
|
95
|
+
|
|
96
|
+
# .lf/*
|
|
97
|
+
lf_dir = repo_root / ".lf"
|
|
98
|
+
if lf_dir.exists():
|
|
99
|
+
for p in lf_dir.iterdir():
|
|
100
|
+
if not p.is_file():
|
|
101
|
+
continue
|
|
102
|
+
# Skip config files
|
|
103
|
+
if p.name in ("config.yaml", "config.yml"):
|
|
104
|
+
continue
|
|
105
|
+
# Task name is filename without extension
|
|
106
|
+
if p.suffix:
|
|
107
|
+
tasks.add(p.stem)
|
|
108
|
+
else:
|
|
109
|
+
tasks.add(p.name)
|
|
110
|
+
|
|
111
|
+
return sorted(tasks)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def list_all_tasks(repo_root: Path | None) -> tuple[list[str], list[str]]:
|
|
115
|
+
"""Return (user_tasks, builtin_only_tasks) for discoverability.
|
|
116
|
+
|
|
117
|
+
User tasks include any that override builtins.
|
|
118
|
+
Builtin-only tasks are builtins not overridden by user tasks.
|
|
119
|
+
"""
|
|
120
|
+
builtins = set(list_builtin_tasks())
|
|
121
|
+
if repo_root:
|
|
122
|
+
user = set(list_user_tasks(repo_root))
|
|
123
|
+
else:
|
|
124
|
+
user = set()
|
|
125
|
+
|
|
126
|
+
builtin_only = builtins - user
|
|
127
|
+
return sorted(user), sorted(builtin_only)
|
|
128
|
+
|
|
129
|
+
|
|
68
130
|
def gather_task(repo_root: Path, name: str) -> TaskFile | None:
|
|
69
131
|
"""Gather and parse task file with frontmatter.
|
|
70
132
|
|
|
@@ -74,6 +136,7 @@ def gather_task(repo_root: Path, name: str) -> TaskFile | None:
|
|
|
74
136
|
3. .lf/{name}.md
|
|
75
137
|
4. .lf/{name}.* (any other extension)
|
|
76
138
|
5. .lf/{name} (bare name)
|
|
139
|
+
6. templates/commands/{name}.md (builtin fallback)
|
|
77
140
|
|
|
78
141
|
Returns TaskFile with parsed config, or None if not found.
|
|
79
142
|
"""
|
|
@@ -109,6 +172,13 @@ def gather_task(repo_root: Path, name: str) -> TaskFile | None:
|
|
|
109
172
|
content = _read_file_if_named(lf_dir, name)
|
|
110
173
|
if content:
|
|
111
174
|
return parse_task_file(name, content)
|
|
175
|
+
|
|
176
|
+
# Fall back to builtin templates
|
|
177
|
+
builtin_path = _get_builtin_task(name)
|
|
178
|
+
if builtin_path:
|
|
179
|
+
content = builtin_path.read_text()
|
|
180
|
+
return parse_task_file(name, content)
|
|
181
|
+
|
|
112
182
|
return None
|
|
113
183
|
|
|
114
184
|
|
|
@@ -250,7 +320,7 @@ def gather_prompt_components(
|
|
|
250
320
|
# Merge: diff files first, then context paths not already in diff
|
|
251
321
|
diff_set = set(diff_file_paths)
|
|
252
322
|
all_file_paths = diff_file_paths + [p for p in context_paths if p not in diff_set]
|
|
253
|
-
|
|
323
|
+
gather_result = gather_files(all_file_paths, repo_root, context_exclude)
|
|
254
324
|
|
|
255
325
|
clipboard = _read_clipboard() if paste else None
|
|
256
326
|
|
|
@@ -261,12 +331,13 @@ def gather_prompt_components(
|
|
|
261
331
|
run_mode=run_mode,
|
|
262
332
|
docs=docs,
|
|
263
333
|
diff=diff,
|
|
264
|
-
diff_files=
|
|
334
|
+
diff_files=gather_result.text_files,
|
|
265
335
|
task=task_result,
|
|
266
336
|
repo_root=repo_root,
|
|
267
337
|
clipboard=clipboard,
|
|
268
338
|
loopflow_doc=loopflow_doc,
|
|
269
339
|
voices=loaded_voices,
|
|
340
|
+
image_files=gather_result.image_files or None,
|
|
270
341
|
)
|
|
271
342
|
|
|
272
343
|
|
|
@@ -322,6 +393,9 @@ def format_prompt(components: PromptComponents) -> str:
|
|
|
322
393
|
if components.clipboard:
|
|
323
394
|
parts.append(f"Content from clipboard.\n\n<lf:clipboard>\n{components.clipboard}\n</lf:clipboard>")
|
|
324
395
|
|
|
396
|
+
if components.image_files:
|
|
397
|
+
parts.append(format_image_references(components.image_files, components.repo_root))
|
|
398
|
+
|
|
325
399
|
return "\n\n".join(parts)
|
|
326
400
|
|
|
327
401
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""File library gathering for LLM context."""
|
|
2
2
|
|
|
3
|
+
from dataclasses import dataclass, field
|
|
3
4
|
from functools import lru_cache
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Optional
|
|
@@ -7,10 +8,20 @@ from typing import Optional
|
|
|
7
8
|
import pathspec
|
|
8
9
|
|
|
9
10
|
|
|
11
|
+
@dataclass
|
|
12
|
+
class GatherResult:
|
|
13
|
+
"""Result of gathering files for context."""
|
|
14
|
+
text_files: list[tuple[Path, str]] = field(default_factory=list)
|
|
15
|
+
image_files: list[Path] = field(default_factory=list)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Image extensions (tracked separately, not embedded in text)
|
|
19
|
+
_IMAGE_EXTENSIONS: set[str] = {
|
|
20
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".ico",
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
# Known binary extensions (skip without reading)
|
|
11
|
-
_BINARY_EXTENSIONS: set[str] = {
|
|
12
|
-
# Images
|
|
13
|
-
".png", ".jpg", ".jpeg", ".gif", ".ico", ".webp", ".bmp", ".tiff",
|
|
24
|
+
_BINARY_EXTENSIONS: set[str] = _IMAGE_EXTENSIONS | {
|
|
14
25
|
# Archives
|
|
15
26
|
".zip", ".tar", ".gz", ".bz2", ".7z", ".rar",
|
|
16
27
|
# Executables/libraries
|
|
@@ -26,6 +37,11 @@ _BINARY_EXTENSIONS: set[str] = {
|
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
|
|
40
|
+
def is_image(path: Path) -> bool:
|
|
41
|
+
"""Check if file is an image by extension."""
|
|
42
|
+
return path.suffix.lower() in _IMAGE_EXTENSIONS
|
|
43
|
+
|
|
44
|
+
|
|
29
45
|
def is_binary(path: Path) -> bool:
|
|
30
46
|
"""Check if file is binary by extension or content sniffing."""
|
|
31
47
|
# Fast path: check extension
|
|
@@ -166,15 +182,6 @@ def _gather_file(
|
|
|
166
182
|
return (path, path.read_text())
|
|
167
183
|
|
|
168
184
|
|
|
169
|
-
def gather_file(
|
|
170
|
-
path: Path,
|
|
171
|
-
repo_root: Path,
|
|
172
|
-
exclude: Optional[list[str]] = None,
|
|
173
|
-
) -> tuple[Path, str] | None:
|
|
174
|
-
excluded_paths = _compile_exclude_patterns(exclude, repo_root) if exclude else None
|
|
175
|
-
return _gather_file(path, repo_root, excluded_paths)
|
|
176
|
-
|
|
177
|
-
|
|
178
185
|
def _expand_path(path_str: str, repo_root: Path) -> list[Path]:
|
|
179
186
|
"""Expand a path string to a list of file paths.
|
|
180
187
|
|
|
@@ -198,13 +205,15 @@ def _expand_path(path_str: str, repo_root: Path) -> list[Path]:
|
|
|
198
205
|
return []
|
|
199
206
|
|
|
200
207
|
|
|
201
|
-
def gather_files(paths: list[str], repo_root: Path, exclude: Optional[list[str]] = None) ->
|
|
208
|
+
def gather_files(paths: list[str], repo_root: Path, exclude: Optional[list[str]] = None) -> GatherResult:
|
|
202
209
|
"""Gather files and their parent READMEs.
|
|
203
210
|
|
|
204
|
-
Returns
|
|
211
|
+
Returns GatherResult with text files (path, content) and image file paths.
|
|
212
|
+
Images are tracked separately since they can't be embedded in text prompts.
|
|
205
213
|
"""
|
|
206
214
|
seen: set[Path] = set()
|
|
207
|
-
|
|
215
|
+
text_files: list[tuple[Path, str]] = []
|
|
216
|
+
image_files: list[Path] = []
|
|
208
217
|
|
|
209
218
|
# Pre-compile exclude patterns once for the entire gather operation
|
|
210
219
|
excluded_paths = _compile_exclude_patterns(exclude, repo_root) if exclude else None
|
|
@@ -213,19 +222,28 @@ def gather_files(paths: list[str], repo_root: Path, exclude: Optional[list[str]]
|
|
|
213
222
|
expanded = _expand_path(path_str, repo_root)
|
|
214
223
|
|
|
215
224
|
for path in expanded:
|
|
225
|
+
if path in seen:
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
# Check if this is an image
|
|
229
|
+
if path.is_file() and is_image(path) and not _is_ignored(path, repo_root, excluded_paths):
|
|
230
|
+
seen.add(path)
|
|
231
|
+
image_files.append(path)
|
|
232
|
+
continue
|
|
233
|
+
|
|
216
234
|
# Gather parent documentation first
|
|
217
235
|
for doc_path, content in _gather_docs(path, repo_root, excluded_paths):
|
|
218
236
|
if doc_path not in seen:
|
|
219
237
|
seen.add(doc_path)
|
|
220
|
-
|
|
238
|
+
text_files.append((doc_path, content))
|
|
221
239
|
|
|
222
|
-
# Gather the file itself
|
|
240
|
+
# Gather the file itself (skips binary including images)
|
|
223
241
|
file_result = _gather_file(path, repo_root, excluded_paths)
|
|
224
242
|
if file_result and file_result[0] not in seen:
|
|
225
243
|
seen.add(file_result[0])
|
|
226
|
-
|
|
244
|
+
text_files.append(file_result)
|
|
227
245
|
|
|
228
|
-
return
|
|
246
|
+
return GatherResult(text_files=text_files, image_files=image_files)
|
|
229
247
|
|
|
230
248
|
|
|
231
249
|
def format_files(files: list[tuple[Path, str]], repo_root: Path) -> str:
|
|
@@ -240,3 +258,20 @@ def format_files(files: list[tuple[Path, str]], repo_root: Path) -> str:
|
|
|
240
258
|
|
|
241
259
|
body = "\n\n".join(parts)
|
|
242
260
|
return f"Reference files for this task. Includes parent documentation for context.\n\n<lf:files>\n{body}\n</lf:files>"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def format_image_references(images: list[Path], repo_root: Path) -> str:
|
|
264
|
+
"""Format image file references for the prompt.
|
|
265
|
+
|
|
266
|
+
Images can't be embedded in text, but we tell the agent where they are
|
|
267
|
+
so it can read them using its file tools.
|
|
268
|
+
"""
|
|
269
|
+
if not images:
|
|
270
|
+
return ""
|
|
271
|
+
|
|
272
|
+
lines = ["The following images are available. Use your Read tool to view them:"]
|
|
273
|
+
for img in images:
|
|
274
|
+
rel = img.relative_to(repo_root)
|
|
275
|
+
lines.append(f"- {rel}")
|
|
276
|
+
|
|
277
|
+
return "<lf:images>\n" + "\n".join(lines) + "\n</lf:images>"
|
|
@@ -230,6 +230,7 @@ def build_codex_command(
|
|
|
230
230
|
model_variant: str | None = None,
|
|
231
231
|
sandbox_root: Path | None = None,
|
|
232
232
|
workdir: Path | None = None,
|
|
233
|
+
images: list[Path] | None = None,
|
|
233
234
|
) -> list[str]:
|
|
234
235
|
"""Build Codex CLI command for the requested run mode.
|
|
235
236
|
|
|
@@ -250,6 +251,11 @@ def build_codex_command(
|
|
|
250
251
|
if sandbox_root:
|
|
251
252
|
cmd.extend(["--add-dir", str(sandbox_root)])
|
|
252
253
|
|
|
254
|
+
# Attach images via -i flag
|
|
255
|
+
if images:
|
|
256
|
+
for img in images:
|
|
257
|
+
cmd.extend(["-i", str(img)])
|
|
258
|
+
|
|
253
259
|
if skip_permissions:
|
|
254
260
|
# Keep sandboxing but avoid approval prompts in exec mode.
|
|
255
261
|
cmd.extend(["-c", 'approval_policy="never"'])
|
|
@@ -282,6 +288,7 @@ def build_codex_interactive_command(
|
|
|
282
288
|
model_variant: str | None = None,
|
|
283
289
|
sandbox_root: Path | None = None,
|
|
284
290
|
workdir: Path | None = None,
|
|
291
|
+
images: list[Path] | None = None,
|
|
285
292
|
) -> list[str]:
|
|
286
293
|
"""Build Codex CLI command for interactive mode.
|
|
287
294
|
|
|
@@ -297,6 +304,10 @@ def build_codex_interactive_command(
|
|
|
297
304
|
cmd.extend(["--sandbox", "workspace-write"])
|
|
298
305
|
if sandbox_root:
|
|
299
306
|
cmd.extend(["--add-dir", str(sandbox_root)])
|
|
307
|
+
# Attach images via -i flag
|
|
308
|
+
if images:
|
|
309
|
+
for img in images:
|
|
310
|
+
cmd.extend(["-i", str(img)])
|
|
300
311
|
return cmd
|
|
301
312
|
|
|
302
313
|
|
|
@@ -368,10 +379,12 @@ def build_model_command(
|
|
|
368
379
|
model_variant: str | None = None,
|
|
369
380
|
sandbox_root: Path | None = None,
|
|
370
381
|
workdir: Path | None = None,
|
|
382
|
+
images: list[Path] | None = None,
|
|
371
383
|
) -> list[str]:
|
|
372
384
|
"""Build a model command for auto/background execution.
|
|
373
385
|
|
|
374
386
|
Prompt should be appended as a CLI argument.
|
|
387
|
+
Images are passed to Codex via -i flag; Claude/Gemini read from filesystem.
|
|
375
388
|
"""
|
|
376
389
|
if model == "claude":
|
|
377
390
|
return build_claude_command(auto=auto, stream=stream, skip_permissions=skip_permissions, model_variant=model_variant)
|
|
@@ -391,6 +404,7 @@ def build_model_command(
|
|
|
391
404
|
model_variant=model_variant,
|
|
392
405
|
sandbox_root=sandbox_root,
|
|
393
406
|
workdir=workdir,
|
|
407
|
+
images=images,
|
|
394
408
|
)
|
|
395
409
|
|
|
396
410
|
|
|
@@ -400,10 +414,12 @@ def build_model_interactive_command(
|
|
|
400
414
|
model_variant: str | None = None,
|
|
401
415
|
sandbox_root: Path | None = None,
|
|
402
416
|
workdir: Path | None = None,
|
|
417
|
+
images: list[Path] | None = None,
|
|
403
418
|
) -> list[str]:
|
|
404
419
|
"""Build a model command for interactive execution.
|
|
405
420
|
|
|
406
421
|
Prompt should be appended as a CLI argument.
|
|
422
|
+
Images are passed to Codex via -i flag; Claude/Gemini read from filesystem.
|
|
407
423
|
"""
|
|
408
424
|
if model == "claude":
|
|
409
425
|
return build_claude_command(auto=False, stream=False, skip_permissions=skip_permissions, model_variant=model_variant)
|
|
@@ -419,6 +435,7 @@ def build_model_interactive_command(
|
|
|
419
435
|
model_variant=model_variant,
|
|
420
436
|
sandbox_root=sandbox_root,
|
|
421
437
|
workdir=workdir,
|
|
438
|
+
images=images,
|
|
422
439
|
)
|
|
423
440
|
|
|
424
441
|
|