skillflow-py 1.2.0__tar.gz → 1.3.0__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 (93) hide show
  1. {skillflow_py-1.2.0/src/skillflow_py.egg-info → skillflow_py-1.3.0}/PKG-INFO +20 -2
  2. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/README.md +19 -1
  3. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/pyproject.toml +1 -1
  4. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/core.py +138 -63
  5. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/graph.py +8 -2
  6. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/write_tools.py +120 -8
  7. {skillflow_py-1.2.0 → skillflow_py-1.3.0/src/skillflow_py.egg-info}/PKG-INFO +20 -2
  8. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_core.py +6 -5
  9. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_integration_configs.py +117 -0
  10. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_write_tools.py +97 -6
  11. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/LICENSE +0 -0
  12. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/setup.cfg +0 -0
  13. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/__init__.py +0 -0
  14. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/agent_registry.py +0 -0
  15. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/context.py +0 -0
  16. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/convert_cli.py +0 -0
  17. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/exceptions.py +0 -0
  18. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/notifications.py +0 -0
  19. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/outbox.py +0 -0
  20. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/linter/__init__.py +0 -0
  21. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/linter/cli.py +0 -0
  22. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/linter/tools/skillflow_lint/impl.py +0 -0
  23. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/linter/tools/skillflow_lint/tool.yaml +0 -0
  24. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_converter/AGENT.md +0 -0
  25. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_converter/__init__.py +0 -0
  26. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_converter/converter.py +0 -0
  27. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_converter/prompts/analyze_skill.md +0 -0
  28. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_converter/prompts/design_graph.md +0 -0
  29. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_converter/prompts/explain_design.md +0 -0
  30. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_converter/prompts/fix_issues.md +0 -0
  31. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_converter/skill_converter.yaml +0 -0
  32. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_runner/AGENT.md +0 -0
  33. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_runner/__init__.py +0 -0
  34. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_runner/runner.py +0 -0
  35. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/read_tools.py +0 -0
  36. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/recovery.py +0 -0
  37. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/run_cli.py +0 -0
  38. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/schema.py +0 -0
  39. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/step_validation.py +0 -0
  40. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tool_loader.py +0 -0
  41. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/__init__.py +0 -0
  42. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/dir_tree/impl.py +0 -0
  43. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/dir_tree/tool.yaml +0 -0
  44. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/draft_commit/impl.py +0 -0
  45. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/draft_commit/tool.yaml +0 -0
  46. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/file_exists/impl.py +0 -0
  47. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/file_exists/tool.yaml +0 -0
  48. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/git_sync_pre/impl.py +0 -0
  49. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/git_sync_pre/tool.yaml +0 -0
  50. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/json_schema/impl.py +0 -0
  51. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/json_schema/tool.yaml +0 -0
  52. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/lint/impl.py +0 -0
  53. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/lint/tool.yaml +0 -0
  54. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/list_tree/impl.py +0 -0
  55. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/list_tree/tool.yaml +0 -0
  56. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/notify/impl.py +0 -0
  57. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/notify/tool.yaml +0 -0
  58. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/pytest/impl.py +0 -0
  59. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/pytest/tool.yaml +0 -0
  60. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/read_file/impl.py +0 -0
  61. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/read_file/tool.yaml +0 -0
  62. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/repo_apply/impl.py +0 -0
  63. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/repo_apply/tool.yaml +0 -0
  64. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/repo_validate/impl.py +0 -0
  65. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/repo_validate/tool.yaml +0 -0
  66. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/write/impl.py +0 -0
  67. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/write/tool.yaml +0 -0
  68. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/validation.py +0 -0
  69. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/workspace.py +0 -0
  70. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow_py.egg-info/SOURCES.txt +0 -0
  71. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow_py.egg-info/dependency_links.txt +0 -0
  72. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow_py.egg-info/entry_points.txt +0 -0
  73. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow_py.egg-info/requires.txt +0 -0
  74. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow_py.egg-info/top_level.txt +0 -0
  75. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_context.py +0 -0
  76. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_exceptions.py +0 -0
  77. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_existing_repo_apply.py +0 -0
  78. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_git_sync_pre.py +0 -0
  79. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_graph.py +0 -0
  80. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_integration.py +0 -0
  81. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_migration_regression.py +0 -0
  82. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_notifications.py +0 -0
  83. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_outbox.py +0 -0
  84. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_read_tools_exec.py +0 -0
  85. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_read_tools_label.py +0 -0
  86. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_recovery.py +0 -0
  87. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_regression.py +0 -0
  88. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_step_validation.py +0 -0
  89. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_tool_loader.py +0 -0
  90. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_tool_lock_contention.py +0 -0
  91. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_tools.py +0 -0
  92. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_validation.py +0 -0
  93. {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillflow-py
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Config-agnostic LLM pipeline graph executor — turn skills into flows
5
5
  Author: Skillflow contributors
6
6
  License-Expression: MIT
@@ -207,10 +207,15 @@ This powers both **inner review loops** (e.g. `review → implement`, `max_loop:
207
207
  context:
208
208
  - source: { step: "1" }
209
209
  - source: { step: "2", mode: "interfaces" }
210
- - source: { config: "meta", output: "brief.md" }
210
+ - source: { config: "meta", output: "brief.md" } # any step of another config
211
+ - source: { config: "meta", step: "finalize", output: "x.json" } # a SPECIFIC step's output
211
212
  - source: { tool: "dir_tree" }
212
213
  ```
213
214
 
215
+ A cross-config source without `step` scans the other config's step dirs for the
216
+ file; adding `step` reads that one step's output (use it when only a specific
217
+ producing step is authoritative).
218
+
214
219
  ## Checkpoints
215
220
 
216
221
  Agent steps can pause for human approval (`tests/fixtures/checkpoint_cycle.yaml`). On reject, the feedback is injected (via the `_feedback` channel) so the re-run knows *why* it was rejected:
@@ -268,6 +273,8 @@ end_conditions:
268
273
  - type: node_reached
269
274
  node: "5_review"
270
275
  result: "completed"
276
+ require_completed: true # fire only once the node has COMPLETED, not merely
277
+ # been reached (i.e. become current_node)
271
278
  - type: max_total_steps
272
279
  limit: 200
273
280
  - type: max_run_duration_seconds
@@ -276,6 +283,17 @@ end_conditions:
276
283
  flag: { fatal_error: true }
277
284
  ```
278
285
 
286
+ `require_completed` (node_reached only) gates termination on the node's step
287
+ reaching `completed` status. Use it when the terminal node is a real agent/tool
288
+ step that must execute before the run ends — without it the condition fires as
289
+ soon as the node becomes `current_node`.
290
+
291
+ **Terminating a run needs an end condition.** A transition `to: null` does NOT by
292
+ itself end a run: with no resolvable target the run is marked **failed** ("no
293
+ matching transition"). To finish cleanly, give the terminal step `to: null` **and**
294
+ a `node_reached` end condition for that node (the pattern above). This applies to
295
+ `tool` and `gate` steps too, not just `agent` steps.
296
+
279
297
  ## Stale Claim Recovery
280
298
 
281
299
  Built into `advance_run`. A claim whose worker died before calling `confirm`, and that is older than `stale_threshold_seconds` (default 300), is auto-reset to `pending` and re-claimed:
@@ -183,10 +183,15 @@ This powers both **inner review loops** (e.g. `review → implement`, `max_loop:
183
183
  context:
184
184
  - source: { step: "1" }
185
185
  - source: { step: "2", mode: "interfaces" }
186
- - source: { config: "meta", output: "brief.md" }
186
+ - source: { config: "meta", output: "brief.md" } # any step of another config
187
+ - source: { config: "meta", step: "finalize", output: "x.json" } # a SPECIFIC step's output
187
188
  - source: { tool: "dir_tree" }
188
189
  ```
189
190
 
191
+ A cross-config source without `step` scans the other config's step dirs for the
192
+ file; adding `step` reads that one step's output (use it when only a specific
193
+ producing step is authoritative).
194
+
190
195
  ## Checkpoints
191
196
 
192
197
  Agent steps can pause for human approval (`tests/fixtures/checkpoint_cycle.yaml`). On reject, the feedback is injected (via the `_feedback` channel) so the re-run knows *why* it was rejected:
@@ -244,6 +249,8 @@ end_conditions:
244
249
  - type: node_reached
245
250
  node: "5_review"
246
251
  result: "completed"
252
+ require_completed: true # fire only once the node has COMPLETED, not merely
253
+ # been reached (i.e. become current_node)
247
254
  - type: max_total_steps
248
255
  limit: 200
249
256
  - type: max_run_duration_seconds
@@ -252,6 +259,17 @@ end_conditions:
252
259
  flag: { fatal_error: true }
253
260
  ```
254
261
 
262
+ `require_completed` (node_reached only) gates termination on the node's step
263
+ reaching `completed` status. Use it when the terminal node is a real agent/tool
264
+ step that must execute before the run ends — without it the condition fires as
265
+ soon as the node becomes `current_node`.
266
+
267
+ **Terminating a run needs an end condition.** A transition `to: null` does NOT by
268
+ itself end a run: with no resolvable target the run is marked **failed** ("no
269
+ matching transition"). To finish cleanly, give the terminal step `to: null` **and**
270
+ a `node_reached` end condition for that node (the pattern above). This applies to
271
+ `tool` and `gate` steps too, not just `agent` steps.
272
+
255
273
  ## Stale Claim Recovery
256
274
 
257
275
  Built into `advance_run`. A claim whose worker died before calling `confirm`, and that is older than `stale_threshold_seconds` (default 300), is auto-reset to `pending` and re-claimed:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "skillflow-py"
7
- version = "1.2.0"
7
+ version = "1.3.0"
8
8
  description = "Config-agnostic LLM pipeline graph executor — turn skills into flows"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -861,10 +861,15 @@ class SkillFlow:
861
861
  if k.startswith("[") and k not in inputs_with_tools["_resolved_context"]:
862
862
  inputs_with_tools["_resolved_context"][k] = v
863
863
 
864
- # Merge dynamic write tool schemas from graph's output.fixed
865
- if node.output_mode and node.output_fixed:
864
+ # Merge dynamic write tool schemas derived from the step's output
865
+ # contract (mode). Write-mode without fixed slots → create/edit
866
+ # (+ write only when allow_full_write); content-mode → write_/create_/
867
+ # edit_ per slot. Single source of truth for mutation tools.
868
+ if node.output_mode:
866
869
  from skillflow.write_tools import generate_write_tool_schemas
867
- for ws in generate_write_tool_schemas(node.output_mode, node.output_fixed):
870
+ for ws in generate_write_tool_schemas(
871
+ node.output_mode, node.output_fixed,
872
+ allow_full_write=node.output_allow_full_write):
868
873
  tool_schemas[ws["name"]] = ws
869
874
 
870
875
  # Merge dynamic read tool schemas from graph's context specs
@@ -927,6 +932,14 @@ class SkillFlow:
927
932
  tmp_dir = self._workspace.get_step_tmp_dir(
928
933
  run["project_id"], run["graph_name"], node.id
929
934
  )
935
+ # Reset staging at the start of each attempt so create/edit/write
936
+ # rebuild from a clean baseline (the repo). Without this, a partial
937
+ # result from a failed attempt pollutes the next one — e.g. a
938
+ # re-issued edit(old→new) would fail because 'old' was already
939
+ # replaced last attempt. Keeps step execution idempotent on retry.
940
+ import shutil
941
+ if tmp_dir.exists():
942
+ shutil.rmtree(str(tmp_dir))
930
943
  tmp_dir.mkdir(parents=True, exist_ok=True)
931
944
  inputs_with_tools["_output_dir"] = str(tmp_dir)
932
945
  if node.output_fixed:
@@ -1817,16 +1830,20 @@ class SkillFlow:
1817
1830
  items = flat
1818
1831
  return items, False
1819
1832
 
1820
- def _reset_loop_body_edge_counts(self, conn, run_id, resolver,
1821
- loop_step_id, body_target):
1822
- """Clear edge counts for the loop body so each iteration gets a fresh
1823
- retry budget.
1824
-
1825
- Inner review/verify loops (e.g. t_impl_review→t_impl, max_loop=3) are
1826
- counted per (run, from, to) — i.e. shared across every iteration. That
1827
- starves later tasks of retries. Resetting the body's edge counts when a
1828
- new item is dispatched scopes the budget to the current iteration.
1833
+ def _loop_body_nodes(self, resolver, loop_step_id) -> set[str]:
1834
+ """The set of node ids that form a loop's body: reachable from the
1835
+ loop's body transition, following transitions, excluding the loop node
1836
+ itself. Purely topological — derived from the graph, no config-specific
1837
+ names — so it stays config-agnostic.
1829
1838
  """
1839
+ node = resolver.get_node(loop_step_id)
1840
+ if not node:
1841
+ return set()
1842
+ body_target = None
1843
+ for t in node.transitions:
1844
+ if t.to:
1845
+ body_target = t.to
1846
+ break
1830
1847
  body_nodes: set[str] = set()
1831
1848
  stack = [body_target]
1832
1849
  while stack:
@@ -1834,12 +1851,25 @@ class SkillFlow:
1834
1851
  if not nid or nid in body_nodes or nid == loop_step_id:
1835
1852
  continue
1836
1853
  body_nodes.add(nid)
1837
- node = resolver.get_node(nid)
1838
- if not node:
1854
+ n = resolver.get_node(nid)
1855
+ if not n:
1839
1856
  continue
1840
- for t in node.transitions:
1857
+ for t in n.transitions:
1841
1858
  if t.to and t.to != loop_step_id:
1842
1859
  stack.append(t.to)
1860
+ return body_nodes
1861
+
1862
+ def _reset_loop_body_edge_counts(self, conn, run_id, resolver,
1863
+ loop_step_id, body_target):
1864
+ """Clear edge counts for the loop body so each iteration gets a fresh
1865
+ retry budget.
1866
+
1867
+ Inner review/verify loops (e.g. t_impl_review→t_impl, max_loop=3) are
1868
+ counted per (run, from, to) — i.e. shared across every iteration. That
1869
+ starves later tasks of retries. Resetting the body's edge counts when a
1870
+ new item is dispatched scopes the budget to the current iteration.
1871
+ """
1872
+ body_nodes = self._loop_body_nodes(resolver, loop_step_id)
1843
1873
  if not body_nodes:
1844
1874
  return
1845
1875
  placeholders = ",".join("?" for _ in body_nodes)
@@ -1849,6 +1879,38 @@ class SkillFlow:
1849
1879
  (run_id, *sorted(body_nodes)),
1850
1880
  )
1851
1881
 
1882
+ def _credit_loop_current_item(self, conn, run_id: str, loop_step_id: str) -> None:
1883
+ """Progression (write): mark the loop's current_item completed.
1884
+
1885
+ Called from confirm_step when a body cycle's terminal step routes back to
1886
+ the loop node — exactly once per completed body cycle, atomic with the
1887
+ terminal step's completion. _resolve_loop performs NO crediting (it only
1888
+ reads completed_items to pick the next item), so resolution is idempotent
1889
+ and the separate advance/claim transactions can't cause a spurious
1890
+ advance or skipped item.
1891
+ """
1892
+ row = conn.execute(
1893
+ "SELECT completed_items, current_item FROM skillflow_loop_state "
1894
+ "WHERE run_id = ? AND loop_step_id = ?",
1895
+ (run_id, loop_step_id),
1896
+ ).fetchone()
1897
+ if not row or not row["current_item"]:
1898
+ return
1899
+ completed: set[str] = set()
1900
+ if row["completed_items"]:
1901
+ try:
1902
+ completed = set(self._deserialize(row["completed_items"]))
1903
+ except Exception:
1904
+ pass
1905
+ if row["current_item"] in completed:
1906
+ return # idempotent — already credited
1907
+ completed.add(row["current_item"])
1908
+ conn.execute(
1909
+ "UPDATE skillflow_loop_state SET completed_items = ?, "
1910
+ "updated_at = datetime('now') WHERE run_id = ? AND loop_step_id = ?",
1911
+ (self._serialize(sorted(completed)), run_id, loop_step_id),
1912
+ )
1913
+
1852
1914
  def _resolve_loop(self, conn, run: dict, resolver, loop_step_id: str) -> str | None:
1853
1915
  """Resolve a loop step to either its body or done transition.
1854
1916
 
@@ -1907,42 +1969,25 @@ class SkillFlow:
1907
1969
  return t.to
1908
1970
  return None
1909
1971
 
1910
- # ── Completed set ────────────────────────────────────────────────
1972
+ # ── Completed set — scoped to the LIVE manifest ───────────────────
1973
+ # Resolution is READ-ONLY w.r.t. progression: it never credits
1974
+ # completion (confirm_step does that via _credit_loop_current_item when a
1975
+ # body cycle returns to the loop). So re-entry — e.g. an extra scheduler
1976
+ # tick in the dispatch→claim gap — re-picks the SAME item with
1977
+ # current_item unchanged, never spuriously advancing or skipping work.
1911
1978
  completed: set[str] = set()
1912
- prev_item: str | None = None
1979
+ current_item: str | None = None
1913
1980
  if row:
1914
1981
  if row["completed_items"]:
1915
1982
  try:
1916
1983
  completed = set(self._deserialize(row["completed_items"]))
1917
1984
  except Exception:
1918
1985
  pass
1919
- prev_item = row["current_item"] or None
1920
- # Backward compat: old rows with current_index (no completed_items).
1921
- # current_index points to the LAST DISPATCHED item.
1922
- # Items BEFORE current_index are completed; items[current_index] is
1923
- # the currently-executing item. Only reconstruct prev_item when at
1924
- # least one item has finished (so we have a "previously dispatched"
1925
- # item to mark completed).
1926
- if not completed and row and row["current_item"] is None:
1927
- try:
1928
- old_items = self._deserialize(row["items_json"]) if row["items_json"] else []
1929
- old_idx = row["current_index"]
1930
- if isinstance(old_idx, int) and old_idx > 0:
1931
- completed = set(old_items[:old_idx])
1932
- if old_idx < len(old_items):
1933
- prev_item = old_items[old_idx]
1934
- elif isinstance(old_idx, int) and old_idx == 0:
1935
- pass # first dispatch — nothing completed, nothing to mark
1936
- except Exception:
1937
- pass
1938
-
1939
- # Mark the previously-dispatched item as completed.
1940
- # prev_item is None on the very first dispatch (row is None),
1941
- # so nothing is marked. On every subsequent entry, prev_item
1942
- # was set by the previous dispatch and has now finished.
1943
- if prev_item and prev_item not in completed:
1944
- completed.add(prev_item)
1945
- prev_item = None
1986
+ current_item = row["current_item"] or None
1987
+ # Drop superseded names from prior goal-loop rounds so completed_items
1988
+ # reflects only the active manifest (prevents the len(completed) overcount
1989
+ # and the scheduler's idx-out-of-range).
1990
+ completed &= set(items)
1946
1991
 
1947
1992
  if row is None:
1948
1993
  if not items:
@@ -1955,13 +2000,6 @@ class SkillFlow:
1955
2000
  (run["id"], loop_step_id, self._serialize(items),
1956
2001
  loop_cfg.item_as or "loop_item"),
1957
2002
  )
1958
- else:
1959
- # Always persist the live manifest so context resolution sees it
1960
- conn.execute(
1961
- "UPDATE skillflow_loop_state SET items_json = ?, "
1962
- "updated_at = datetime('now') WHERE run_id = ? AND loop_step_id = ?",
1963
- (self._serialize(items), run["id"], loop_step_id),
1964
- )
1965
2003
 
1966
2004
  # ── Find first uncompleted item (manifest order) ────────────────
1967
2005
  if not items:
@@ -1974,17 +2012,23 @@ class SkillFlow:
1974
2012
  if next_item is None:
1975
2013
  return _route_done()
1976
2014
 
1977
- # ── Dispatch ─────────────────────────────────────────────────────
2015
+ # ── Dispatch (idempotent) ─────────────────────────────────────────
2016
+ # Persist the live manifest + scoped completed set + the item to run.
2017
+ new_dispatch = (next_item != current_item)
1978
2018
  conn.execute(
1979
- "UPDATE skillflow_loop_state SET completed_items = ?, "
2019
+ "UPDATE skillflow_loop_state SET items_json = ?, completed_items = ?, "
1980
2020
  "current_item = ?, updated_at = datetime('now') "
1981
2021
  "WHERE run_id = ? AND loop_step_id = ?",
1982
- (self._serialize(sorted(completed)), next_item,
1983
- run["id"], loop_step_id),
1984
- )
1985
- self._reset_loop_body_edge_counts(
1986
- conn, run["id"], resolver, loop_step_id, body_target
2022
+ (self._serialize(items), self._serialize(sorted(completed)),
2023
+ next_item, run["id"], loop_step_id),
1987
2024
  )
2025
+ # Reset the body's per-iteration retry budget ONLY for a genuinely new
2026
+ # item — never on an idempotent re-entry for the same in-flight item,
2027
+ # which would wipe the body's mid-cycle edge counts.
2028
+ if new_dispatch:
2029
+ self._reset_loop_body_edge_counts(
2030
+ conn, run["id"], resolver, loop_step_id, body_target
2031
+ )
1988
2032
  return body_target
1989
2033
 
1990
2034
  def _resolve_next_in_tx(self, conn, run_id: str, step_id: str,
@@ -2043,7 +2087,19 @@ class SkillFlow:
2043
2087
  tool_node = resolver.get_node(t.to)
2044
2088
  if tool_node and not self._should_delegate_tool(tool_node.tool_name):
2045
2089
  skip_tool = True
2046
- if resolver.is_gate(t.to) or resolver.is_loop(t.to) or skip_tool:
2090
+ if resolver.is_loop(t.to):
2091
+ # PROGRESSION: credit the loop's current_item — but ONLY when
2092
+ # THIS completing step is inside the loop body (a body cycle
2093
+ # returning). An EXTERNAL step transitioning INTO the loop
2094
+ # (entry / goal-loop re-entry) must NOT credit: that would mark
2095
+ # the in-flight item complete and skip it. Topological check
2096
+ # (no config-specific names). Fires once per body cycle; a stray
2097
+ # re-tick at the loop node goes through advance_run only (no
2098
+ # credit), so resolution stays idempotent.
2099
+ if step_id in self._loop_body_nodes(resolver, t.to):
2100
+ self._credit_loop_current_item(conn, run_id, t.to)
2101
+ return None
2102
+ if resolver.is_gate(t.to) or skip_tool:
2047
2103
  return None
2048
2104
 
2049
2105
  # Check max_loop on this edge
@@ -3057,9 +3113,11 @@ class SkillFlow:
3057
3113
  ac = self.agent_registry.get(node.agent_config)
3058
3114
  if ac:
3059
3115
  allowed.update(ac.tools)
3060
- if node.output_mode and node.output_fixed:
3116
+ if node.output_mode:
3061
3117
  from skillflow.write_tools import generate_write_tool_schemas
3062
- for ws in generate_write_tool_schemas(node.output_mode, node.output_fixed):
3118
+ for ws in generate_write_tool_schemas(
3119
+ node.output_mode, node.output_fixed,
3120
+ allow_full_write=node.output_allow_full_write):
3063
3121
  allowed.add(ws["name"])
3064
3122
  # Add read tool names from context specs (mode ∈ {tool, both})
3065
3123
  if node.context:
@@ -3092,13 +3150,25 @@ class SkillFlow:
3092
3150
  else:
3093
3151
  return execute_write(slot, fixed, params, str(tmp_dir))
3094
3152
 
3095
- if name == "write":
3153
+ # Generic write-mode tools (mode: write, no fixed slots): create new
3154
+ # files / edit existing ones surgically. edit reads its baseline from
3155
+ # the consolidated repo (project_root) but writes the whole result into
3156
+ # staging — the repo is only ever mutated by on_deliver:repo_apply.
3157
+ if name in ("create", "edit", "write"):
3096
3158
  if not self._workspace:
3097
3159
  return {"error": "No workspace configured for write tool"}
3098
3160
  pid = self._get_project_id(run_id)
3099
3161
  gname = self._get_graph_name(run_id)
3100
3162
  tmp_dir = self._workspace.get_step_tmp_dir(pid, gname, step_id)
3101
- from skillflow.write_tools import execute_generic_write
3163
+ from skillflow.write_tools import (execute_generic_create,
3164
+ execute_generic_edit,
3165
+ execute_generic_write)
3166
+ if name == "create":
3167
+ return execute_generic_create(params, str(tmp_dir),
3168
+ source_dir=project_root or "")
3169
+ if name == "edit":
3170
+ return execute_generic_edit(params, str(tmp_dir),
3171
+ source_dir=project_root or "")
3102
3172
  return execute_generic_write(params, str(tmp_dir))
3103
3173
 
3104
3174
  # finish_step — no-op completion signal; the host runner detects it and
@@ -3111,6 +3181,11 @@ class SkillFlow:
3111
3181
  kwargs = dict(params)
3112
3182
  kwargs.setdefault("workspace_root", project_root or "")
3113
3183
  kwargs.setdefault("project_root", project_root or "")
3184
+ # Forward step/run identity so tools that want per-step state (e.g.
3185
+ # scratch-file tools) can isolate by step. Signature-filtered below, so
3186
+ # tools that don't declare these params are unaffected.
3187
+ kwargs.setdefault("step_id", step_id or "")
3188
+ kwargs.setdefault("run_id", run_id or "")
3114
3189
  # SF-10: pass step staging/output dirs so read_file (and similar tools)
3115
3190
  # can find files the agent just wrote (in .tmp) or files from previous
3116
3191
  # retries (in the step's final dir). write_* tools write to .tmp; without
@@ -117,6 +117,7 @@ class StepNode:
117
117
  context: list[dict] = field(default_factory=list)
118
118
  output_mode: str = ""
119
119
  output_fixed: dict = field(default_factory=dict)
120
+ output_allow_full_write: bool = False
120
121
  validation: list[dict] = field(default_factory=list)
121
122
  notify: list[str] | None = None # event types to push (None = outbox only)
122
123
  lifecycle: dict[str, Any] = field(default_factory=dict)
@@ -264,6 +265,7 @@ class PipelineGraph:
264
265
  context=s.get("context", []),
265
266
  output_mode=(s.get("output") or {}).get("mode", "") or s.get("output_mode", ""),
266
267
  output_fixed=(s.get("output") or {}).get("fixed", {}),
268
+ output_allow_full_write=bool((s.get("output") or {}).get("allow_full_write", False)),
267
269
  validation=s.get("validation", []),
268
270
  notify=s.get("notify"),
269
271
  lifecycle=s.get("lifecycle", {}),
@@ -337,8 +339,12 @@ class PipelineGraph:
337
339
  sd["context"] = s.context
338
340
  if s.output_mode:
339
341
  sd["output_mode"] = s.output_mode
340
- if s.output_fixed:
341
- sd["output"] = {"fixed": s.output_fixed}
342
+ if s.output_fixed or s.output_allow_full_write:
343
+ sd["output"] = {}
344
+ if s.output_fixed:
345
+ sd["output"]["fixed"] = s.output_fixed
346
+ if s.output_allow_full_write:
347
+ sd["output"]["allow_full_write"] = True
342
348
  if s.validation:
343
349
  sd["validation"] = s.validation
344
350
  if s.lifecycle:
@@ -87,31 +87,74 @@ def _archive_old_file(directory: Path, base_name: str) -> str | None:
87
87
 
88
88
 
89
89
  def generate_write_tool_schemas(output_mode: str,
90
- fixed: dict) -> list[dict]:
91
- """Generate tool schema dicts for write/create/append tools.
90
+ fixed: dict,
91
+ allow_full_write: bool = False) -> list[dict]:
92
+ """Generate tool schema dicts for write/create/edit/append tools.
92
93
 
93
94
  Returns a list of dicts with 'name', 'description', 'parameters'.
95
+
96
+ For generic write-mode (``mode: write`` with no fixed slots) the default
97
+ tools are ``create`` (new files) + ``edit`` (surgical in-place change) —
98
+ NOT whole-file ``write``. Rewriting a whole existing file from the model's
99
+ (necessarily partial) view silently drops any region it didn't reproduce;
100
+ ``edit`` carries the rest of the file through verbatim, so that failure mode
101
+ is impossible. Whole-file ``write`` is exposed only when ``allow_full_write``
102
+ is set on the step (rare: genuine from-scratch authorship).
94
103
  """
95
104
  if output_mode == "write" and not fixed:
96
- return [{
97
- "name": "write",
98
- "description": "Write a file. The 'file' param must be a bare filename.",
105
+ tools = [{
106
+ "name": "create",
107
+ "description": (
108
+ "Create a NEW file with the given content. The 'file' param is a "
109
+ "repo-relative path (e.g. 'core/db_manager.py'). Fails if the file "
110
+ "already exists — use 'edit' to change an existing file."
111
+ ),
99
112
  "parameters": {
100
113
  "file": {"type": "string", "required": True},
101
114
  "content": {"type": "string", "required": True},
102
115
  },
103
116
  }, {
117
+ "name": "edit",
118
+ "description": (
119
+ "Surgically change an EXISTING file by replacing an exact, unique "
120
+ "snippet — use this to fix or update part of a file without "
121
+ "rewriting the whole thing (the rest is preserved verbatim). "
122
+ "'old_str' must appear exactly once; include surrounding context "
123
+ "to make it unique. Fails if the file is absent or 'old_str' isn't "
124
+ "found exactly once. For multiple changes, call edit repeatedly."
125
+ ),
126
+ "parameters": {
127
+ "file": {"type": "string", "required": True},
128
+ "old_str": {"type": "string", "required": True,
129
+ "description": "Exact text to find (must appear exactly once)."},
130
+ "new_str": {"type": "string", "required": True,
131
+ "description": "Replacement text."},
132
+ },
133
+ }]
134
+ if allow_full_write:
135
+ tools.append({
136
+ "name": "write",
137
+ "description": ("Write a whole file, replacing it entirely if it "
138
+ "exists. Prefer 'edit' for existing files."),
139
+ "parameters": {
140
+ "file": {"type": "string", "required": True},
141
+ "content": {"type": "string", "required": True},
142
+ },
143
+ })
144
+ tools.append({
104
145
  "name": "finish_step",
105
146
  "description": (
106
147
  "Signal that all required output files have been written and "
107
- "the step is complete. Call this ONLY after all write tool calls "
108
- "in the current turn have been made — it must be the last call."
148
+ "the step is complete. Call this ONLY after all create/edit/write "
149
+ "tool calls in the current turn have been made — it must be the "
150
+ "last call."
109
151
  ),
110
152
  "parameters": {
111
153
  "summary": {"type": "string", "required": False,
112
154
  "description": "Brief summary of what was created or completed"},
113
155
  },
114
- }]
156
+ })
157
+ return tools
115
158
 
116
159
  if output_mode == "content":
117
160
  tools = []
@@ -336,3 +379,72 @@ def execute_generic_write(params: dict, output_dir: str) -> dict:
336
379
  path.parent.mkdir(parents=True, exist_ok=True)
337
380
  path.write_text(_ensure_str(params.get("content", "")), encoding="utf-8")
338
381
  return {"written": str(Path(*safe_parts))}
382
+
383
+
384
+ def execute_generic_create(params: dict, output_dir: str,
385
+ source_dir: str = "") -> dict:
386
+ """Execute a generic create(file, content) call for new repo files.
387
+
388
+ Writes the whole file into ``output_dir`` (staging). Refuses to create a
389
+ file that already exists in staging or in the consolidated repo
390
+ (``source_dir``) — existing files must be changed with ``edit``, so a
391
+ create can never silently clobber an existing file's contents.
392
+ """
393
+ raw = params.get("file") or params.get("filename") or params.get("path", "")
394
+ safe_parts = normalize_repo_path(raw)
395
+ if not safe_parts:
396
+ return {"error": "Invalid filename: path traversal denied"}
397
+ rel = str(Path(*safe_parts))
398
+ staged = Path(output_dir) / rel
399
+ repo = (Path(source_dir) / rel) if source_dir else None
400
+ if staged.exists() or (repo is not None and repo.exists()):
401
+ return {"error": (f"create: '{rel}' already exists — use 'edit' to change "
402
+ f"an existing file (create is for new files only).")}
403
+ staged.parent.mkdir(parents=True, exist_ok=True)
404
+ staged.write_text(_ensure_str(params.get("content", "")), encoding="utf-8")
405
+ return {"written": rel}
406
+
407
+
408
+ def execute_generic_edit(params: dict, output_dir: str,
409
+ source_dir: str = "") -> dict:
410
+ """Execute a generic edit(file, old_str, new_str) call on an EXISTING file.
411
+
412
+ Baseline is staging-first: read ``output_dir/file`` if a prior edit/create
413
+ this same step already produced it (so repeated edits to one file compound),
414
+ else the consolidated repo ``source_dir/file``. Requires ``old_str`` to
415
+ match exactly once; writes the whole spliced result into staging, from where
416
+ promotion + repo_apply overwrites the repo copy. The repo is never edited in
417
+ place — ``edit`` only reads it as a baseline.
418
+ """
419
+ raw = params.get("file") or params.get("filename") or params.get("path", "")
420
+ safe_parts = normalize_repo_path(raw)
421
+ if not safe_parts:
422
+ return {"error": "Invalid filename: path traversal denied"}
423
+ rel = str(Path(*safe_parts))
424
+ old_str = _ensure_str(params.get("old_str", ""))
425
+ new_str = _ensure_str(params.get("new_str", ""))
426
+ if not old_str:
427
+ return {"error": "edit: 'old_str' is required and must be non-empty"}
428
+
429
+ staged = Path(output_dir) / rel
430
+ repo = (Path(source_dir) / rel) if source_dir else None
431
+ if staged.exists():
432
+ src = staged
433
+ elif repo is not None and repo.exists():
434
+ src = repo
435
+ else:
436
+ return {"error": (f"edit: cannot edit '{rel}' — file does not exist "
437
+ f"(use 'create' for a new file).")}
438
+
439
+ content = src.read_text(encoding="utf-8")
440
+ occurrences = content.count(old_str)
441
+ if occurrences == 0:
442
+ return {"error": f"edit: 'old_str' not found in '{rel}'"}
443
+ if occurrences > 1:
444
+ return {"error": (f"edit: 'old_str' matches {occurrences} times in '{rel}' "
445
+ f"— include more surrounding context to make it unique")}
446
+
447
+ updated = content.replace(old_str, new_str, 1)
448
+ staged.parent.mkdir(parents=True, exist_ok=True)
449
+ staged.write_text(updated, encoding="utf-8")
450
+ return {"edited": rel}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillflow-py
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Config-agnostic LLM pipeline graph executor — turn skills into flows
5
5
  Author: Skillflow contributors
6
6
  License-Expression: MIT
@@ -207,10 +207,15 @@ This powers both **inner review loops** (e.g. `review → implement`, `max_loop:
207
207
  context:
208
208
  - source: { step: "1" }
209
209
  - source: { step: "2", mode: "interfaces" }
210
- - source: { config: "meta", output: "brief.md" }
210
+ - source: { config: "meta", output: "brief.md" } # any step of another config
211
+ - source: { config: "meta", step: "finalize", output: "x.json" } # a SPECIFIC step's output
211
212
  - source: { tool: "dir_tree" }
212
213
  ```
213
214
 
215
+ A cross-config source without `step` scans the other config's step dirs for the
216
+ file; adding `step` reads that one step's output (use it when only a specific
217
+ producing step is authoritative).
218
+
214
219
  ## Checkpoints
215
220
 
216
221
  Agent steps can pause for human approval (`tests/fixtures/checkpoint_cycle.yaml`). On reject, the feedback is injected (via the `_feedback` channel) so the re-run knows *why* it was rejected:
@@ -268,6 +273,8 @@ end_conditions:
268
273
  - type: node_reached
269
274
  node: "5_review"
270
275
  result: "completed"
276
+ require_completed: true # fire only once the node has COMPLETED, not merely
277
+ # been reached (i.e. become current_node)
271
278
  - type: max_total_steps
272
279
  limit: 200
273
280
  - type: max_run_duration_seconds
@@ -276,6 +283,17 @@ end_conditions:
276
283
  flag: { fatal_error: true }
277
284
  ```
278
285
 
286
+ `require_completed` (node_reached only) gates termination on the node's step
287
+ reaching `completed` status. Use it when the terminal node is a real agent/tool
288
+ step that must execute before the run ends — without it the condition fires as
289
+ soon as the node becomes `current_node`.
290
+
291
+ **Terminating a run needs an end condition.** A transition `to: null` does NOT by
292
+ itself end a run: with no resolvable target the run is marked **failed** ("no
293
+ matching transition"). To finish cleanly, give the terminal step `to: null` **and**
294
+ a `node_reached` end condition for that node (the pattern above). This applies to
295
+ `tool` and `gate` steps too, not just `agent` steps.
296
+
279
297
  ## Stale Claim Recovery
280
298
 
281
299
  Built into `advance_run`. A claim whose worker died before calling `confirm`, and that is older than `stale_threshold_seconds` (default 300), is auto-reset to `pending` and re-claimed: