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.
Files changed (94) hide show
  1. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/PKG-INFO +1 -1
  2. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/pyproject.toml +1 -1
  3. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/core.py +52 -3
  4. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/read_tools.py +31 -8
  5. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/repo_apply/impl.py +17 -26
  6. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/repo_apply/tool.yaml +4 -0
  7. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow_py.egg-info/PKG-INFO +1 -1
  8. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow_py.egg-info/SOURCES.txt +1 -0
  9. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_core.py +105 -0
  10. skillflow_py-1.4.1/tests/test_noop_floor_fix.py +65 -0
  11. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_tools.py +61 -1
  12. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/LICENSE +0 -0
  13. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/README.md +0 -0
  14. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/setup.cfg +0 -0
  15. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/__init__.py +0 -0
  16. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/agent_registry.py +0 -0
  17. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/context.py +0 -0
  18. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/convert_cli.py +0 -0
  19. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/exceptions.py +0 -0
  20. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/graph.py +0 -0
  21. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/notifications.py +0 -0
  22. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/outbox.py +0 -0
  23. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/linter/__init__.py +0 -0
  24. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/linter/cli.py +0 -0
  25. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/linter/tools/skillflow_lint/impl.py +0 -0
  26. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/linter/tools/skillflow_lint/tool.yaml +0 -0
  27. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/AGENT.md +0 -0
  28. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/__init__.py +0 -0
  29. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/converter.py +0 -0
  30. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/prompts/analyze_skill.md +0 -0
  31. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/prompts/design_graph.md +0 -0
  32. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/prompts/explain_design.md +0 -0
  33. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/prompts/fix_issues.md +0 -0
  34. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_converter/skill_converter.yaml +0 -0
  35. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_runner/AGENT.md +0 -0
  36. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_runner/__init__.py +0 -0
  37. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/plugins/skill_runner/runner.py +0 -0
  38. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/recovery.py +0 -0
  39. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/run_cli.py +0 -0
  40. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/schema.py +0 -0
  41. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/step_validation.py +0 -0
  42. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tool_loader.py +0 -0
  43. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/__init__.py +0 -0
  44. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/dir_tree/impl.py +0 -0
  45. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/dir_tree/tool.yaml +0 -0
  46. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/draft_commit/impl.py +0 -0
  47. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/draft_commit/tool.yaml +0 -0
  48. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/file_exists/impl.py +0 -0
  49. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/file_exists/tool.yaml +0 -0
  50. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/git_sync_pre/impl.py +0 -0
  51. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/git_sync_pre/tool.yaml +0 -0
  52. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/json_schema/impl.py +0 -0
  53. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/json_schema/tool.yaml +0 -0
  54. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/lint/impl.py +0 -0
  55. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/lint/tool.yaml +0 -0
  56. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/list_tree/impl.py +0 -0
  57. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/list_tree/tool.yaml +0 -0
  58. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/notify/impl.py +0 -0
  59. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/notify/tool.yaml +0 -0
  60. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/pytest/impl.py +0 -0
  61. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/pytest/tool.yaml +0 -0
  62. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/read_file/impl.py +0 -0
  63. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/read_file/tool.yaml +0 -0
  64. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/repo_validate/impl.py +0 -0
  65. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/repo_validate/tool.yaml +0 -0
  66. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/write/impl.py +0 -0
  67. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/tools/write/tool.yaml +0 -0
  68. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/validation.py +0 -0
  69. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/workspace.py +0 -0
  70. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow/write_tools.py +0 -0
  71. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow_py.egg-info/dependency_links.txt +0 -0
  72. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow_py.egg-info/entry_points.txt +0 -0
  73. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow_py.egg-info/requires.txt +0 -0
  74. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/src/skillflow_py.egg-info/top_level.txt +0 -0
  75. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_context.py +0 -0
  76. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_exceptions.py +0 -0
  77. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_existing_repo_apply.py +0 -0
  78. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_git_sync_pre.py +0 -0
  79. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_graph.py +0 -0
  80. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_integration.py +0 -0
  81. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_integration_configs.py +0 -0
  82. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_migration_regression.py +0 -0
  83. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_notifications.py +0 -0
  84. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_outbox.py +0 -0
  85. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_read_tools_exec.py +0 -0
  86. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_read_tools_label.py +0 -0
  87. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_recovery.py +0 -0
  88. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_regression.py +0 -0
  89. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_step_validation.py +0 -0
  90. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_tool_loader.py +0 -0
  91. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_tool_lock_contention.py +0 -0
  92. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_validation.py +0 -0
  93. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_workspace.py +0 -0
  94. {skillflow_py-1.3.2 → skillflow_py-1.4.1}/tests/test_write_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillflow-py
3
- Version: 1.3.2
3
+ Version: 1.4.1
4
4
  Summary: Config-agnostic LLM pipeline graph executor — turn skills into flows
5
5
  Author: Skillflow contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "skillflow-py"
7
- version = "1.3.2"
7
+ version = "1.4.1"
8
8
  description = "Config-agnostic LLM pipeline graph executor — turn skills into flows"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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
- on_failure = hook_spec.get("on_failure", "fail") if isinstance(hook_spec, dict) else "fail"
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 dicts (multi-check): used for after_deliver
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
- return self._execute_check_hook(token, node, hook_name, hook_spec)
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
- rel = f.relative_to(d)
417
- st = f.stat()
418
- entries.append({
419
- "name": str(rel),
420
- "size": st.st_size,
421
- })
422
- return json.dumps(entries, ensure_ascii=False)
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
- return {"applied": False, "files": [],
61
- "error": "No files to apply (empty or all filtered)"}
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillflow-py
3
- Version: 1.3.2
3
+ Version: 1.4.1
4
4
  Summary: Config-agnostic LLM pipeline graph executor — turn skills into flows
5
5
  Author: Skillflow contributors
6
6
  License-Expression: MIT
@@ -76,6 +76,7 @@ tests/test_graph.py
76
76
  tests/test_integration.py
77
77
  tests/test_integration_configs.py
78
78
  tests/test_migration_regression.py
79
+ tests/test_noop_floor_fix.py
79
80
  tests/test_notifications.py
80
81
  tests/test_outbox.py
81
82
  tests/test_read_tools_exec.py
@@ -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
- assert result["applied"] is False
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