skillflow-py 1.3.2__tar.gz → 1.4.1__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.
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/PKG-INFO +1 -1
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/pyproject.toml +1 -1
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/core.py +52 -3
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/read_tools.py +31 -8
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/repo_apply/impl.py +17 -26
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/repo_apply/tool.yaml +4 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow_py.egg-info/PKG-INFO +1 -1
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow_py.egg-info/SOURCES.txt +1 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_core.py +105 -0
- skillflow_py-1.4.1/tests/test_noop_floor_fix.py +65 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_tools.py +61 -1
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/LICENSE +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/README.md +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/setup.cfg +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/__init__.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/agent_registry.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/context.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/convert_cli.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/exceptions.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/graph.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/notifications.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/outbox.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/linter/__init__.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/linter/cli.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/linter/tools/skillflow_lint/impl.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/linter/tools/skillflow_lint/tool.yaml +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/AGENT.md +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/__init__.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/converter.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/prompts/analyze_skill.md +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/prompts/design_graph.md +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/prompts/explain_design.md +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/prompts/fix_issues.md +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/skill_converter.yaml +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_runner/AGENT.md +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_runner/__init__.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_runner/runner.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/recovery.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/run_cli.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/schema.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/step_validation.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tool_loader.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/__init__.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/dir_tree/impl.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/dir_tree/tool.yaml +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/draft_commit/impl.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/draft_commit/tool.yaml +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/file_exists/impl.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/file_exists/tool.yaml +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/git_sync_pre/impl.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/git_sync_pre/tool.yaml +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/json_schema/impl.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/json_schema/tool.yaml +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/lint/impl.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/lint/tool.yaml +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/list_tree/impl.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/list_tree/tool.yaml +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/notify/impl.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/notify/tool.yaml +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/pytest/impl.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/pytest/tool.yaml +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/read_file/impl.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/read_file/tool.yaml +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/repo_validate/impl.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/repo_validate/tool.yaml +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/write/impl.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/write/tool.yaml +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/validation.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/workspace.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/write_tools.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow_py.egg-info/dependency_links.txt +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow_py.egg-info/entry_points.txt +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow_py.egg-info/requires.txt +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow_py.egg-info/top_level.txt +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_context.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_exceptions.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_existing_repo_apply.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_git_sync_pre.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_graph.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_integration.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_integration_configs.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_migration_regression.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_notifications.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_outbox.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_read_tools_exec.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_read_tools_label.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_recovery.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_regression.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_step_validation.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_tool_loader.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_tool_lock_contention.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_validation.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_workspace.py +0 -0
- {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_write_tools.py +0 -0
|
@@ -1057,7 +1057,11 @@ class SkillFlow:
|
|
|
1057
1057
|
|
|
1058
1058
|
if not hook_result.get("passed", False):
|
|
1059
1059
|
error = hook_result.get("error", f"Lifecycle hook '{hook_name}' failed")
|
|
1060
|
-
|
|
1060
|
+
# A tool-hook sequence bubbles the failing item's on_failure
|
|
1061
|
+
# in the result; fall back to the spec-level value otherwise.
|
|
1062
|
+
on_failure = hook_result.get("on_failure") or (
|
|
1063
|
+
hook_spec.get("on_failure", "fail")
|
|
1064
|
+
if isinstance(hook_spec, dict) else "fail")
|
|
1061
1065
|
if on_failure == "retry":
|
|
1062
1066
|
self._emit_lifecycle_event(token, hook_name, "retry", error)
|
|
1063
1067
|
self._handle_lifecycle_retry(token, error)
|
|
@@ -1255,19 +1259,64 @@ class SkillFlow:
|
|
|
1255
1259
|
|
|
1256
1260
|
hook_spec can be:
|
|
1257
1261
|
- A dict with 'tool' (single tool call): used for after_validate, on_deliver
|
|
1258
|
-
- A list of
|
|
1262
|
+
- A list of validation specs (multi-check): used for after_deliver
|
|
1263
|
+
- A list of {'tool', 'params'} dicts (sequential tool hooks): used for
|
|
1264
|
+
on_deliver when several repo-mutating tools must run in order (e.g.
|
|
1265
|
+
repo_apply then repo_delete). Each runs via _execute_tool_hook, so
|
|
1266
|
+
$STEP_DIR is resolved and project_root injected — unlike check specs,
|
|
1267
|
+
which run through StepValidator and receive neither.
|
|
1259
1268
|
|
|
1260
1269
|
Returns {passed: bool, error?: str}.
|
|
1261
1270
|
"""
|
|
1262
1271
|
self._emit_lifecycle_event(token, hook_name, "started")
|
|
1263
1272
|
|
|
1264
1273
|
if isinstance(hook_spec, list):
|
|
1265
|
-
|
|
1274
|
+
# after_deliver runs validation checks against the project repo;
|
|
1275
|
+
# other slots (on_deliver) run a sequence of tool hooks with full
|
|
1276
|
+
# variable resolution.
|
|
1277
|
+
if hook_name == "after_deliver":
|
|
1278
|
+
return self._execute_check_hook(token, node, hook_name, hook_spec)
|
|
1279
|
+
return self._execute_tool_hook_sequence(token, node, hook_name, hook_spec)
|
|
1266
1280
|
elif isinstance(hook_spec, dict) and "tool" in hook_spec:
|
|
1267
1281
|
return self._execute_tool_hook(token, node, hook_name, hook_spec)
|
|
1268
1282
|
else:
|
|
1269
1283
|
return {"passed": False, "error": f"Invalid hook spec for '{hook_name}'"}
|
|
1270
1284
|
|
|
1285
|
+
def _execute_tool_hook_sequence(self, token: ClaimToken, node: StepNode,
|
|
1286
|
+
hook_name: str, items: list) -> dict:
|
|
1287
|
+
"""Run a list of tool hooks in order (e.g. repo_apply then repo_delete).
|
|
1288
|
+
|
|
1289
|
+
Each item is a ``{'tool', 'params', 'on_failure'?}`` dict executed via
|
|
1290
|
+
:meth:`_execute_tool_hook` (full variable resolution + project_root
|
|
1291
|
+
injection). Per-item ``on_failure`` is honored: ``warn``/``skip`` log and
|
|
1292
|
+
continue to the next item; ``retry``/``fail`` stop the sequence and
|
|
1293
|
+
bubble up (carrying the item's ``on_failure``) so the caller applies the
|
|
1294
|
+
right step-level outcome.
|
|
1295
|
+
"""
|
|
1296
|
+
detail_files = None
|
|
1297
|
+
for item in items:
|
|
1298
|
+
if not (isinstance(item, dict) and "tool" in item):
|
|
1299
|
+
return {"passed": False,
|
|
1300
|
+
"error": f"Invalid tool hook in '{hook_name}': {item!r}"}
|
|
1301
|
+
res = self._execute_tool_hook(token, node, hook_name, item)
|
|
1302
|
+
if not res.get("passed", True):
|
|
1303
|
+
on_failure = item.get("on_failure", "fail")
|
|
1304
|
+
if on_failure in ("warn", "skip"):
|
|
1305
|
+
self._emit_lifecycle_event(
|
|
1306
|
+
token, hook_name,
|
|
1307
|
+
"warned" if on_failure == "warn" else "skipped",
|
|
1308
|
+
res.get("error", ""))
|
|
1309
|
+
continue
|
|
1310
|
+
# retry / fail: stop and let the caller apply the step outcome.
|
|
1311
|
+
return {**res, "on_failure": on_failure}
|
|
1312
|
+
files = res.get("files")
|
|
1313
|
+
if isinstance(files, list):
|
|
1314
|
+
detail_files = files
|
|
1315
|
+
out = {"passed": True}
|
|
1316
|
+
if detail_files is not None:
|
|
1317
|
+
out["files"] = detail_files
|
|
1318
|
+
return out
|
|
1319
|
+
|
|
1271
1320
|
def _execute_tool_hook(self, token: ClaimToken, node: StepNode,
|
|
1272
1321
|
hook_name: str, hook_spec: dict) -> dict:
|
|
1273
1322
|
"""Execute a tool-type lifecycle hook (single tool call)."""
|
|
@@ -27,6 +27,21 @@ from pathlib import Path
|
|
|
27
27
|
# the ``truncated`` flag signals when there is more to page through.
|
|
28
28
|
_MAX_READ_LINES = 2000
|
|
29
29
|
|
|
30
|
+
# Directories never worth enumerating/searching as "source" — VCS internals and
|
|
31
|
+
# build/dependency caches. Mirrors dir_tree/list_tree's BLOCKED set, which the
|
|
32
|
+
# generated directory read tools previously lacked: a single list_repo_root on a
|
|
33
|
+
# real repo dumped the whole .git tree (~64% of files) into the agent's context.
|
|
34
|
+
_BLOCKED_DIR_PARTS = {".git", "__pycache__", ".venv", "node_modules",
|
|
35
|
+
".mypy_cache", ".pytest_cache", ".ruff_cache"}
|
|
36
|
+
# Hard cap on entries returned by a directory listing so one call can't flood
|
|
37
|
+
# the context with thousands of paths.
|
|
38
|
+
_MAX_LIST_ENTRIES = 1000
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_blocked_path(rel) -> bool:
|
|
42
|
+
"""True if any path component is a blocked VCS/build directory."""
|
|
43
|
+
return any(part in _BLOCKED_DIR_PARTS for part in Path(rel).parts)
|
|
44
|
+
|
|
30
45
|
|
|
31
46
|
def _page_lines(text: str, start_line: int = 0, end_line: int | None = None) -> dict:
|
|
32
47
|
"""Slice file text into a line-numbered window with paging metadata.
|
|
@@ -407,19 +422,25 @@ def make_read_tool_fns(specs: list[dict], workspace_root: str,
|
|
|
407
422
|
|
|
408
423
|
def _list_dir(_paths=dir_paths) -> str:
|
|
409
424
|
entries = []
|
|
425
|
+
truncated = False
|
|
410
426
|
for dp in _paths:
|
|
411
427
|
d = Path(dp)
|
|
412
428
|
if not d.is_dir():
|
|
413
429
|
continue
|
|
414
430
|
for f in sorted(d.rglob("*")):
|
|
415
|
-
if f.is_file() and f.name != ".gitkeep":
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
431
|
+
if not (f.is_file() and f.name != ".gitkeep"):
|
|
432
|
+
continue
|
|
433
|
+
rel = f.relative_to(d)
|
|
434
|
+
if _is_blocked_path(rel):
|
|
435
|
+
continue # skip .git / build / dependency caches
|
|
436
|
+
entries.append({"name": str(rel), "size": f.stat().st_size})
|
|
437
|
+
if len(entries) >= _MAX_LIST_ENTRIES:
|
|
438
|
+
truncated = True
|
|
439
|
+
break
|
|
440
|
+
if truncated:
|
|
441
|
+
break
|
|
442
|
+
return json.dumps({"files": entries, "truncated": truncated},
|
|
443
|
+
ensure_ascii=False)
|
|
423
444
|
|
|
424
445
|
def _read_dir_file(name: str, start_line: int = 0,
|
|
425
446
|
end_line: int | None = None, _paths=dir_paths) -> dict:
|
|
@@ -465,6 +486,8 @@ def make_read_tool_fns(specs: list[dict], workspace_root: str,
|
|
|
465
486
|
for f in sorted(d.rglob(glob) if glob else d.rglob("*")):
|
|
466
487
|
if not f.is_file() or f.name == ".gitkeep":
|
|
467
488
|
continue
|
|
489
|
+
if _is_blocked_path(f.relative_to(d)):
|
|
490
|
+
continue # skip .git / build / dependency caches
|
|
468
491
|
# Skip binary-looking files
|
|
469
492
|
if f.suffix in (".pyc", ".pyo", ".so", ".o", ".bin"):
|
|
470
493
|
continue
|
|
@@ -1,39 +1,31 @@
|
|
|
1
1
|
"""Apply files from draft directory to project repo."""
|
|
2
2
|
|
|
3
|
+
import fnmatch
|
|
3
4
|
import shutil
|
|
4
5
|
import subprocess
|
|
5
|
-
import sys
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
def _probe(msg: str) -> None:
|
|
10
|
-
"""Diagnostic log for the step-dir-contains-whole-repo investigation.
|
|
11
|
-
|
|
12
|
-
Prints to stderr with a greppable marker so it shows in `docker logs`.
|
|
13
|
-
"""
|
|
14
|
-
print(f"[repo_apply.probe] {msg}", file=sys.stderr, flush=True)
|
|
15
|
-
|
|
16
|
-
|
|
17
9
|
def repo_apply(source_dir: str, *, workspace_root: str = "",
|
|
18
10
|
project_root: str = "",
|
|
19
11
|
step_id: str = "", project_id: str = "",
|
|
20
|
-
task_name: str = "") -> dict:
|
|
12
|
+
task_name: str = "", ignore=None) -> dict:
|
|
21
13
|
src = Path(source_dir)
|
|
22
14
|
if not src.is_absolute():
|
|
23
15
|
src = Path(workspace_root) / source_dir
|
|
24
16
|
src = src.resolve()
|
|
25
17
|
dst = Path(project_root).resolve()
|
|
26
18
|
|
|
27
|
-
# PROBE: what is repo_apply actually reading from, and is it the repo itself?
|
|
28
|
-
_probe(f"step={step_id} project={project_id} task={task_name!r} "
|
|
29
|
-
f"source_dir_param={source_dir!r} resolved_src={src} "
|
|
30
|
-
f"project_root={dst} workspace_root={workspace_root!r} "
|
|
31
|
-
f"src==dst? {src == dst}")
|
|
32
|
-
|
|
33
19
|
if not src.exists():
|
|
34
20
|
return {"applied": False, "files": [],
|
|
35
21
|
"error": f"Source dir not found: {source_dir}"}
|
|
36
22
|
|
|
23
|
+
# Caller-supplied ignore globs (fnmatch), matched against BOTH the path
|
|
24
|
+
# relative to source_dir AND the basename — so "web/js/_x.json" and "_x.json"
|
|
25
|
+
# style patterns both work. Lets a host keep control/scratch files (e.g. a
|
|
26
|
+
# deletions manifest) in the step dir without them landing in the repo.
|
|
27
|
+
ignore_globs = tuple(ignore or ())
|
|
28
|
+
|
|
37
29
|
applied_files = []
|
|
38
30
|
for f in sorted(src.rglob("*")):
|
|
39
31
|
if not f.is_file():
|
|
@@ -43,22 +35,21 @@ def repo_apply(source_dir: str, *, workspace_root: str = "",
|
|
|
43
35
|
if ".git/" in str(f):
|
|
44
36
|
continue
|
|
45
37
|
rel = f.relative_to(src)
|
|
38
|
+
if ignore_globs and any(
|
|
39
|
+
fnmatch.fnmatch(str(rel), pat) or fnmatch.fnmatch(f.name, pat)
|
|
40
|
+
for pat in ignore_globs
|
|
41
|
+
):
|
|
42
|
+
continue
|
|
46
43
|
target = dst / rel
|
|
47
44
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
48
45
|
shutil.copy2(f, target)
|
|
49
46
|
applied_files.append(str(rel))
|
|
50
47
|
|
|
51
|
-
# PROBE: how many files did we just apply? A handful = normal surgical edit;
|
|
52
|
-
# dozens/hundreds = the leak. Dump a sample so we can see WHAT leaked.
|
|
53
|
-
if len(applied_files) > 15:
|
|
54
|
-
_probe(f"LARGE APPLY step={step_id}: {len(applied_files)} files from "
|
|
55
|
-
f"{src} — first 15: {applied_files[:15]}")
|
|
56
|
-
else:
|
|
57
|
-
_probe(f"step={step_id}: applied {len(applied_files)} file(s): {applied_files}")
|
|
58
|
-
|
|
59
48
|
if not applied_files:
|
|
60
|
-
|
|
61
|
-
|
|
49
|
+
# Empty source = a legitimate no-op step (the agent determined no change
|
|
50
|
+
# was needed and produced no output). Nothing to copy or commit; report
|
|
51
|
+
# success so the on_deliver hook doesn't retry/fail a clean no-op.
|
|
52
|
+
return {"applied": True, "files": [], "committed": False}
|
|
62
53
|
|
|
63
54
|
# git add + commit
|
|
64
55
|
r = subprocess.run(["git", "add", "-A"], cwd=dst,
|
|
@@ -8,3 +8,7 @@ parameters:
|
|
|
8
8
|
type: string
|
|
9
9
|
description: "Source directory path (absolute or relative to workspace)"
|
|
10
10
|
required: true
|
|
11
|
+
ignore:
|
|
12
|
+
type: array
|
|
13
|
+
description: "Optional list of fnmatch globs (matched against the path relative to source_dir and the basename). Matching files are NOT copied to the repo — use for host control/scratch files kept in the step dir (e.g. a deletions manifest)."
|
|
14
|
+
required: false
|
|
@@ -1142,6 +1142,111 @@ def test_lifecycle_events_in_outbox(sf: SkillFlow, tmp_path: Path):
|
|
|
1142
1142
|
assert step["status"] == "completed"
|
|
1143
1143
|
|
|
1144
1144
|
|
|
1145
|
+
def test_on_deliver_list_runs_tools_end_to_end(sf: SkillFlow, tmp_path: Path):
|
|
1146
|
+
"""on_deliver as a LIST runs its tool hooks (not validation checks): a
|
|
1147
|
+
repo_apply in list form resolves $STEP_DIR and commits into the repo.
|
|
1148
|
+
|
|
1149
|
+
Regression: a list previously routed to StepValidator (check hook), which
|
|
1150
|
+
neither unwraps ``params`` nor resolves ``$STEP_DIR`` — so a repo-mutating
|
|
1151
|
+
tool placed there silently no-op'd.
|
|
1152
|
+
"""
|
|
1153
|
+
import subprocess
|
|
1154
|
+
repo = tmp_path / "repo"
|
|
1155
|
+
repo.mkdir()
|
|
1156
|
+
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
|
1157
|
+
subprocess.run(["git", "config", "user.email", "t@t"], cwd=repo, check=True)
|
|
1158
|
+
subprocess.run(["git", "config", "user.name", "t"], cwd=repo, check=True)
|
|
1159
|
+
|
|
1160
|
+
sf._tool_loader = ToolLoader(
|
|
1161
|
+
Path(__file__).parent.parent / "src" / "skillflow" / "tools")
|
|
1162
|
+
sf._workspace = WorkspaceManager(
|
|
1163
|
+
str(tmp_path / "ws"), projects_base=str(tmp_path / "projects"),
|
|
1164
|
+
code_path_resolver=lambda pid: str(repo),
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
node = StepNode(
|
|
1168
|
+
id="s1", step_type="agent", output_mode="content",
|
|
1169
|
+
output_fixed={"out": "main.py"},
|
|
1170
|
+
lifecycle={"on_deliver": [
|
|
1171
|
+
{"tool": "repo_apply", "params": {"source_dir": "$STEP_DIR"}},
|
|
1172
|
+
]},
|
|
1173
|
+
transitions=[Transition(to=None)],
|
|
1174
|
+
)
|
|
1175
|
+
g = PipelineGraph(name="ondeliver_list", begin="s1", steps=[node])
|
|
1176
|
+
sf.register_graph(g)
|
|
1177
|
+
|
|
1178
|
+
rid = sf.create_run("ondeliver_list", {"project_id": "p1"})
|
|
1179
|
+
sf.start_run(rid)
|
|
1180
|
+
sf.advance_run(rid)
|
|
1181
|
+
token = sf.claim_next_step(rid)
|
|
1182
|
+
tmp = sf._workspace.get_step_tmp_dir("p1", "ondeliver_list", "s1")
|
|
1183
|
+
(tmp / "main.py").write_text("print('hi')\n")
|
|
1184
|
+
sf.confirm_step(token.token, StepResult(outputs={}, flags={}))
|
|
1185
|
+
|
|
1186
|
+
# Landed AND committed → the list ran the tool hook with $STEP_DIR resolved
|
|
1187
|
+
# (old code would have no-op'd via the check-hook path).
|
|
1188
|
+
assert (repo / "main.py").read_text() == "print('hi')\n"
|
|
1189
|
+
log = subprocess.run(["git", "log", "--oneline"], cwd=repo,
|
|
1190
|
+
capture_output=True, text=True).stdout
|
|
1191
|
+
assert "step: s1" in log, f"no apply commit:\n{log}"
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def test_on_deliver_list_order_and_on_failure(sf: SkillFlow, tmp_path: Path,
|
|
1195
|
+
monkeypatch):
|
|
1196
|
+
"""on_deliver list = ordered tool-hook sequence honoring per-item
|
|
1197
|
+
on_failure; after_deliver list still routes to validation checks."""
|
|
1198
|
+
sf._tool_loader = ToolLoader(
|
|
1199
|
+
Path(__file__).parent.parent / "src" / "skillflow" / "tools")
|
|
1200
|
+
sf._workspace = WorkspaceManager(str(tmp_path / "ws"),
|
|
1201
|
+
projects_base=str(tmp_path / "projects"))
|
|
1202
|
+
node = StepNode(
|
|
1203
|
+
id="s1", step_type="agent", output_mode="content",
|
|
1204
|
+
output_fixed={"out": "r.md"}, transitions=[Transition(to=None)],
|
|
1205
|
+
)
|
|
1206
|
+
g = PipelineGraph(name="seq", begin="s1", steps=[node])
|
|
1207
|
+
sf.register_graph(g)
|
|
1208
|
+
rid = sf.create_run("seq", {"project_id": "pid"})
|
|
1209
|
+
sf.start_run(rid)
|
|
1210
|
+
sf.advance_run(rid)
|
|
1211
|
+
token = sf.claim_next_step(rid).token # the ClaimToken inside the ClaimedStep
|
|
1212
|
+
|
|
1213
|
+
calls: list = []
|
|
1214
|
+
|
|
1215
|
+
def fake_tool_hook(tok, nd, hook_name, spec):
|
|
1216
|
+
calls.append(spec["tool"])
|
|
1217
|
+
if spec["tool"] == "boom":
|
|
1218
|
+
return {"passed": False, "error": "kaboom"}
|
|
1219
|
+
return {"passed": True, "files": ["x"]}
|
|
1220
|
+
monkeypatch.setattr(sf, "_execute_tool_hook", fake_tool_hook)
|
|
1221
|
+
|
|
1222
|
+
# 1) ordering + all-pass → sequence passes, surfaces files detail
|
|
1223
|
+
r = sf._execute_lifecycle_hook(token, node, "on_deliver",
|
|
1224
|
+
[{"tool": "repo_apply"}, {"tool": "repo_delete"}])
|
|
1225
|
+
assert calls == ["repo_apply", "repo_delete"]
|
|
1226
|
+
assert r["passed"] is True and r.get("files") == ["x"]
|
|
1227
|
+
|
|
1228
|
+
# 2) failing item with on_failure=warn → continue; sequence still passes
|
|
1229
|
+
calls.clear()
|
|
1230
|
+
r = sf._execute_lifecycle_hook(token, node, "on_deliver",
|
|
1231
|
+
[{"tool": "boom", "on_failure": "warn"}, {"tool": "repo_delete"}])
|
|
1232
|
+
assert calls == ["boom", "repo_delete"]
|
|
1233
|
+
assert r["passed"] is True
|
|
1234
|
+
|
|
1235
|
+
# 3) failing item with default on_failure=fail → stop + bubble on_failure
|
|
1236
|
+
calls.clear()
|
|
1237
|
+
r = sf._execute_lifecycle_hook(token, node, "on_deliver",
|
|
1238
|
+
[{"tool": "boom"}, {"tool": "repo_delete"}])
|
|
1239
|
+
assert calls == ["boom"]
|
|
1240
|
+
assert r["passed"] is False and r["on_failure"] == "fail"
|
|
1241
|
+
|
|
1242
|
+
# 4) after_deliver list is unchanged: routes to checks, not the tool
|
|
1243
|
+
# sequence, so the faked tool-hook path is never touched.
|
|
1244
|
+
calls.clear()
|
|
1245
|
+
sf._execute_lifecycle_hook(token, node, "after_deliver",
|
|
1246
|
+
[{"tool": "syntax_lint", "files": ["*.nomatch"]}])
|
|
1247
|
+
assert calls == []
|
|
1248
|
+
|
|
1249
|
+
|
|
1145
1250
|
# ── Durable run trace ─────────────────────────────────────────────────
|
|
1146
1251
|
|
|
1147
1252
|
def test_trace_append_and_get(sf: SkillFlow):
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Regression tests for the existing-repo "no-op floor" data-corruption bug.
|
|
2
|
+
|
|
3
|
+
Two skillflow-side guarantees the host (AItelier) relies on after the fix:
|
|
4
|
+
|
|
5
|
+
1. ``repo_apply`` on an EMPTY source dir is a no-op SUCCESS, not an error.
|
|
6
|
+
A legitimate "no change needed" step promotes empty staging; on_deliver
|
|
7
|
+
must not retry/fail it.
|
|
8
|
+
|
|
9
|
+
2. The generated directory read tools (``list_<label>`` / ``search_<label>``)
|
|
10
|
+
exclude ``.git`` and build/dependency caches and cap their output, so a
|
|
11
|
+
single ``list_repo_root`` can't dump the whole ``.git`` tree (tens of
|
|
12
|
+
thousands of tokens) into the agent's context.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from skillflow.read_tools import _is_blocked_path, make_read_tool_fns
|
|
20
|
+
from skillflow.tool_loader import ToolLoader
|
|
21
|
+
|
|
22
|
+
_REAL_TOOLS = Path(__file__).parent.parent / "src" / "skillflow" / "tools"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_repo_apply_empty_source_is_noop_success(tmp_path):
|
|
26
|
+
repo = tmp_path / "repo"
|
|
27
|
+
repo.mkdir()
|
|
28
|
+
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
|
29
|
+
|
|
30
|
+
src = tmp_path / "empty_staging"
|
|
31
|
+
src.mkdir() # no files
|
|
32
|
+
|
|
33
|
+
repo_apply = ToolLoader(_REAL_TOOLS).load_fn("repo_apply")
|
|
34
|
+
res = repo_apply(str(src), project_root=str(repo))
|
|
35
|
+
|
|
36
|
+
assert res["applied"] is True
|
|
37
|
+
assert res["files"] == []
|
|
38
|
+
assert res.get("committed") is False
|
|
39
|
+
assert "error" not in res
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_is_blocked_path_excludes_vcs_and_build_dirs():
|
|
43
|
+
assert _is_blocked_path(Path(".git/objects/ab/cd"))
|
|
44
|
+
assert _is_blocked_path("node_modules/foo/bar.js")
|
|
45
|
+
assert _is_blocked_path("pkg/__pycache__/x.pyc")
|
|
46
|
+
assert not _is_blocked_path("src/main.py")
|
|
47
|
+
assert not _is_blocked_path("docs/readme.md")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_list_repo_tool_excludes_git_tree(tmp_path):
|
|
51
|
+
repo = tmp_path / "repo"
|
|
52
|
+
(repo / ".git" / "objects").mkdir(parents=True)
|
|
53
|
+
(repo / ".git" / "objects" / "deadbeef").write_text("binblob")
|
|
54
|
+
(repo / "src").mkdir()
|
|
55
|
+
(repo / "src" / "main.py").write_text("x = 1\n")
|
|
56
|
+
|
|
57
|
+
specs = [{"source_type": "repository", "mode": "tool"}]
|
|
58
|
+
fns = make_read_tool_fns(specs, str(tmp_path), code_root=str(repo))
|
|
59
|
+
|
|
60
|
+
list_fn = next(fn for name, fn in fns.items() if name.startswith("list_"))
|
|
61
|
+
payload = json.loads(list_fn())
|
|
62
|
+
names = [e["name"] for e in payload["files"]]
|
|
63
|
+
|
|
64
|
+
assert "src/main.py" in names
|
|
65
|
+
assert not any(".git" in n for n in names), names
|
|
@@ -144,7 +144,67 @@ class TestRepoApply:
|
|
|
144
144
|
result = repo_apply(source_dir=str(empty),
|
|
145
145
|
workspace_root=str(tmp_path),
|
|
146
146
|
project_root=str(proj))
|
|
147
|
-
|
|
147
|
+
# Empty source is a legitimate no-op step (agent determined no change
|
|
148
|
+
# needed): report success with no files, not an error that would make
|
|
149
|
+
# the on_deliver hook retry/fail a clean no-op.
|
|
150
|
+
assert result["applied"] is True
|
|
151
|
+
assert result["files"] == []
|
|
152
|
+
assert result.get("committed") is False
|
|
153
|
+
|
|
154
|
+
def test_ignore_globs_not_copied(self, tmp_path):
|
|
155
|
+
"""Files matching an `ignore` glob stay out of the repo (host control
|
|
156
|
+
files kept in the step dir, e.g. a deletions manifest)."""
|
|
157
|
+
proj = tmp_path / "project"
|
|
158
|
+
proj.mkdir()
|
|
159
|
+
subprocess.run(["git", "init", "-b", "main"], cwd=proj,
|
|
160
|
+
capture_output=True)
|
|
161
|
+
subprocess.run(["git", "config", "user.email", "test@test.com"],
|
|
162
|
+
cwd=proj, capture_output=True)
|
|
163
|
+
subprocess.run(["git", "config", "user.name", "Test"], cwd=proj,
|
|
164
|
+
capture_output=True)
|
|
165
|
+
(proj / ".gitkeep").write_text("")
|
|
166
|
+
subprocess.run(["git", "add", "."], cwd=proj, capture_output=True)
|
|
167
|
+
subprocess.run(["git", "commit", "-m", "init"], cwd=proj,
|
|
168
|
+
capture_output=True)
|
|
169
|
+
|
|
170
|
+
draft = tmp_path / "draft"
|
|
171
|
+
draft.mkdir()
|
|
172
|
+
(draft / "keep.py").write_text("x = 1\n")
|
|
173
|
+
(draft / "_deletions.json").write_text('["old.js"]')
|
|
174
|
+
(draft / "sub").mkdir()
|
|
175
|
+
(draft / "sub" / "_deletions.json").write_text('["y"]')
|
|
176
|
+
|
|
177
|
+
result = repo_apply(source_dir=str(draft),
|
|
178
|
+
workspace_root=str(tmp_path),
|
|
179
|
+
project_root=str(proj),
|
|
180
|
+
ignore=["_deletions.json"])
|
|
181
|
+
|
|
182
|
+
assert result["applied"] is True
|
|
183
|
+
assert "keep.py" in result["files"]
|
|
184
|
+
# basename-matched at any depth → never copied, never committed
|
|
185
|
+
assert "_deletions.json" not in result["files"]
|
|
186
|
+
assert "sub/_deletions.json" not in result["files"]
|
|
187
|
+
assert (proj / "keep.py").exists()
|
|
188
|
+
assert not (proj / "_deletions.json").exists()
|
|
189
|
+
assert not (proj / "sub" / "_deletions.json").exists()
|
|
190
|
+
|
|
191
|
+
def test_ignore_default_none_copies_everything(self, tmp_path):
|
|
192
|
+
"""Backward-compat: no `ignore` arg → every file copied (unchanged)."""
|
|
193
|
+
proj = tmp_path / "project"
|
|
194
|
+
proj.mkdir()
|
|
195
|
+
subprocess.run(["git", "init", "-b", "main"], cwd=proj,
|
|
196
|
+
capture_output=True)
|
|
197
|
+
subprocess.run(["git", "config", "user.email", "t@t"], cwd=proj,
|
|
198
|
+
capture_output=True)
|
|
199
|
+
subprocess.run(["git", "config", "user.name", "T"], cwd=proj,
|
|
200
|
+
capture_output=True)
|
|
201
|
+
draft = tmp_path / "draft"
|
|
202
|
+
draft.mkdir()
|
|
203
|
+
(draft / "_deletions.json").write_text("[]")
|
|
204
|
+
result = repo_apply(source_dir=str(draft),
|
|
205
|
+
workspace_root=str(tmp_path),
|
|
206
|
+
project_root=str(proj))
|
|
207
|
+
assert "_deletions.json" in result["files"]
|
|
148
208
|
|
|
149
209
|
|
|
150
210
|
class TestLint:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/linter/tools/skillflow_lint/impl.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/converter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/skill_converter.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|