skillflow-py 1.2.0__tar.gz → 1.2.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 (93) hide show
  1. {skillflow_py-1.2.0/src/skillflow_py.egg-info → skillflow_py-1.2.1}/PKG-INFO +20 -2
  2. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/README.md +19 -1
  3. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/pyproject.toml +1 -1
  4. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/core.py +104 -56
  5. {skillflow_py-1.2.0 → skillflow_py-1.2.1/src/skillflow_py.egg-info}/PKG-INFO +20 -2
  6. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_integration_configs.py +117 -0
  7. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/LICENSE +0 -0
  8. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/setup.cfg +0 -0
  9. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/__init__.py +0 -0
  10. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/agent_registry.py +0 -0
  11. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/context.py +0 -0
  12. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/convert_cli.py +0 -0
  13. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/exceptions.py +0 -0
  14. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/graph.py +0 -0
  15. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/notifications.py +0 -0
  16. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/outbox.py +0 -0
  17. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/linter/__init__.py +0 -0
  18. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/linter/cli.py +0 -0
  19. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/linter/tools/skillflow_lint/impl.py +0 -0
  20. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/linter/tools/skillflow_lint/tool.yaml +0 -0
  21. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_converter/AGENT.md +0 -0
  22. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_converter/__init__.py +0 -0
  23. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_converter/converter.py +0 -0
  24. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_converter/prompts/analyze_skill.md +0 -0
  25. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_converter/prompts/design_graph.md +0 -0
  26. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_converter/prompts/explain_design.md +0 -0
  27. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_converter/prompts/fix_issues.md +0 -0
  28. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_converter/skill_converter.yaml +0 -0
  29. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_runner/AGENT.md +0 -0
  30. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_runner/__init__.py +0 -0
  31. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_runner/runner.py +0 -0
  32. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/read_tools.py +0 -0
  33. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/recovery.py +0 -0
  34. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/run_cli.py +0 -0
  35. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/schema.py +0 -0
  36. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/step_validation.py +0 -0
  37. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tool_loader.py +0 -0
  38. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/__init__.py +0 -0
  39. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/dir_tree/impl.py +0 -0
  40. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/dir_tree/tool.yaml +0 -0
  41. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/draft_commit/impl.py +0 -0
  42. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/draft_commit/tool.yaml +0 -0
  43. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/file_exists/impl.py +0 -0
  44. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/file_exists/tool.yaml +0 -0
  45. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/git_sync_pre/impl.py +0 -0
  46. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/git_sync_pre/tool.yaml +0 -0
  47. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/json_schema/impl.py +0 -0
  48. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/json_schema/tool.yaml +0 -0
  49. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/lint/impl.py +0 -0
  50. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/lint/tool.yaml +0 -0
  51. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/list_tree/impl.py +0 -0
  52. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/list_tree/tool.yaml +0 -0
  53. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/notify/impl.py +0 -0
  54. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/notify/tool.yaml +0 -0
  55. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/pytest/impl.py +0 -0
  56. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/pytest/tool.yaml +0 -0
  57. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/read_file/impl.py +0 -0
  58. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/read_file/tool.yaml +0 -0
  59. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/repo_apply/impl.py +0 -0
  60. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/repo_apply/tool.yaml +0 -0
  61. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/repo_validate/impl.py +0 -0
  62. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/repo_validate/tool.yaml +0 -0
  63. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/write/impl.py +0 -0
  64. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/write/tool.yaml +0 -0
  65. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/validation.py +0 -0
  66. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/workspace.py +0 -0
  67. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/write_tools.py +0 -0
  68. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow_py.egg-info/SOURCES.txt +0 -0
  69. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow_py.egg-info/dependency_links.txt +0 -0
  70. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow_py.egg-info/entry_points.txt +0 -0
  71. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow_py.egg-info/requires.txt +0 -0
  72. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow_py.egg-info/top_level.txt +0 -0
  73. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_context.py +0 -0
  74. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_core.py +0 -0
  75. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_exceptions.py +0 -0
  76. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_existing_repo_apply.py +0 -0
  77. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_git_sync_pre.py +0 -0
  78. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_graph.py +0 -0
  79. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_integration.py +0 -0
  80. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_migration_regression.py +0 -0
  81. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_notifications.py +0 -0
  82. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_outbox.py +0 -0
  83. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_read_tools_exec.py +0 -0
  84. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_read_tools_label.py +0 -0
  85. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_recovery.py +0 -0
  86. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_regression.py +0 -0
  87. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_step_validation.py +0 -0
  88. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_tool_loader.py +0 -0
  89. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_tool_lock_contention.py +0 -0
  90. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_tools.py +0 -0
  91. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_validation.py +0 -0
  92. {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_workspace.py +0 -0
  93. {skillflow_py-1.2.0 → skillflow_py-1.2.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.2.0
3
+ Version: 1.2.1
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.2.1"
8
8
  description = "Config-agnostic LLM pipeline graph executor — turn skills into flows"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1817,16 +1817,20 @@ class SkillFlow:
1817
1817
  items = flat
1818
1818
  return items, False
1819
1819
 
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.
1820
+ def _loop_body_nodes(self, resolver, loop_step_id) -> set[str]:
1821
+ """The set of node ids that form a loop's body: reachable from the
1822
+ loop's body transition, following transitions, excluding the loop node
1823
+ itself. Purely topological — derived from the graph, no config-specific
1824
+ names — so it stays config-agnostic.
1829
1825
  """
1826
+ node = resolver.get_node(loop_step_id)
1827
+ if not node:
1828
+ return set()
1829
+ body_target = None
1830
+ for t in node.transitions:
1831
+ if t.to:
1832
+ body_target = t.to
1833
+ break
1830
1834
  body_nodes: set[str] = set()
1831
1835
  stack = [body_target]
1832
1836
  while stack:
@@ -1834,12 +1838,25 @@ class SkillFlow:
1834
1838
  if not nid or nid in body_nodes or nid == loop_step_id:
1835
1839
  continue
1836
1840
  body_nodes.add(nid)
1837
- node = resolver.get_node(nid)
1838
- if not node:
1841
+ n = resolver.get_node(nid)
1842
+ if not n:
1839
1843
  continue
1840
- for t in node.transitions:
1844
+ for t in n.transitions:
1841
1845
  if t.to and t.to != loop_step_id:
1842
1846
  stack.append(t.to)
1847
+ return body_nodes
1848
+
1849
+ def _reset_loop_body_edge_counts(self, conn, run_id, resolver,
1850
+ loop_step_id, body_target):
1851
+ """Clear edge counts for the loop body so each iteration gets a fresh
1852
+ retry budget.
1853
+
1854
+ Inner review/verify loops (e.g. t_impl_review→t_impl, max_loop=3) are
1855
+ counted per (run, from, to) — i.e. shared across every iteration. That
1856
+ starves later tasks of retries. Resetting the body's edge counts when a
1857
+ new item is dispatched scopes the budget to the current iteration.
1858
+ """
1859
+ body_nodes = self._loop_body_nodes(resolver, loop_step_id)
1843
1860
  if not body_nodes:
1844
1861
  return
1845
1862
  placeholders = ",".join("?" for _ in body_nodes)
@@ -1849,6 +1866,38 @@ class SkillFlow:
1849
1866
  (run_id, *sorted(body_nodes)),
1850
1867
  )
1851
1868
 
1869
+ def _credit_loop_current_item(self, conn, run_id: str, loop_step_id: str) -> None:
1870
+ """Progression (write): mark the loop's current_item completed.
1871
+
1872
+ Called from confirm_step when a body cycle's terminal step routes back to
1873
+ the loop node — exactly once per completed body cycle, atomic with the
1874
+ terminal step's completion. _resolve_loop performs NO crediting (it only
1875
+ reads completed_items to pick the next item), so resolution is idempotent
1876
+ and the separate advance/claim transactions can't cause a spurious
1877
+ advance or skipped item.
1878
+ """
1879
+ row = conn.execute(
1880
+ "SELECT completed_items, current_item FROM skillflow_loop_state "
1881
+ "WHERE run_id = ? AND loop_step_id = ?",
1882
+ (run_id, loop_step_id),
1883
+ ).fetchone()
1884
+ if not row or not row["current_item"]:
1885
+ return
1886
+ completed: set[str] = set()
1887
+ if row["completed_items"]:
1888
+ try:
1889
+ completed = set(self._deserialize(row["completed_items"]))
1890
+ except Exception:
1891
+ pass
1892
+ if row["current_item"] in completed:
1893
+ return # idempotent — already credited
1894
+ completed.add(row["current_item"])
1895
+ conn.execute(
1896
+ "UPDATE skillflow_loop_state SET completed_items = ?, "
1897
+ "updated_at = datetime('now') WHERE run_id = ? AND loop_step_id = ?",
1898
+ (self._serialize(sorted(completed)), run_id, loop_step_id),
1899
+ )
1900
+
1852
1901
  def _resolve_loop(self, conn, run: dict, resolver, loop_step_id: str) -> str | None:
1853
1902
  """Resolve a loop step to either its body or done transition.
1854
1903
 
@@ -1907,42 +1956,25 @@ class SkillFlow:
1907
1956
  return t.to
1908
1957
  return None
1909
1958
 
1910
- # ── Completed set ────────────────────────────────────────────────
1959
+ # ── Completed set — scoped to the LIVE manifest ───────────────────
1960
+ # Resolution is READ-ONLY w.r.t. progression: it never credits
1961
+ # completion (confirm_step does that via _credit_loop_current_item when a
1962
+ # body cycle returns to the loop). So re-entry — e.g. an extra scheduler
1963
+ # tick in the dispatch→claim gap — re-picks the SAME item with
1964
+ # current_item unchanged, never spuriously advancing or skipping work.
1911
1965
  completed: set[str] = set()
1912
- prev_item: str | None = None
1966
+ current_item: str | None = None
1913
1967
  if row:
1914
1968
  if row["completed_items"]:
1915
1969
  try:
1916
1970
  completed = set(self._deserialize(row["completed_items"]))
1917
1971
  except Exception:
1918
1972
  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
1973
+ current_item = row["current_item"] or None
1974
+ # Drop superseded names from prior goal-loop rounds so completed_items
1975
+ # reflects only the active manifest (prevents the len(completed) overcount
1976
+ # and the scheduler's idx-out-of-range).
1977
+ completed &= set(items)
1946
1978
 
1947
1979
  if row is None:
1948
1980
  if not items:
@@ -1955,13 +1987,6 @@ class SkillFlow:
1955
1987
  (run["id"], loop_step_id, self._serialize(items),
1956
1988
  loop_cfg.item_as or "loop_item"),
1957
1989
  )
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
1990
 
1966
1991
  # ── Find first uncompleted item (manifest order) ────────────────
1967
1992
  if not items:
@@ -1974,17 +1999,23 @@ class SkillFlow:
1974
1999
  if next_item is None:
1975
2000
  return _route_done()
1976
2001
 
1977
- # ── Dispatch ─────────────────────────────────────────────────────
2002
+ # ── Dispatch (idempotent) ─────────────────────────────────────────
2003
+ # Persist the live manifest + scoped completed set + the item to run.
2004
+ new_dispatch = (next_item != current_item)
1978
2005
  conn.execute(
1979
- "UPDATE skillflow_loop_state SET completed_items = ?, "
2006
+ "UPDATE skillflow_loop_state SET items_json = ?, completed_items = ?, "
1980
2007
  "current_item = ?, updated_at = datetime('now') "
1981
2008
  "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
2009
+ (self._serialize(items), self._serialize(sorted(completed)),
2010
+ next_item, run["id"], loop_step_id),
1987
2011
  )
2012
+ # Reset the body's per-iteration retry budget ONLY for a genuinely new
2013
+ # item — never on an idempotent re-entry for the same in-flight item,
2014
+ # which would wipe the body's mid-cycle edge counts.
2015
+ if new_dispatch:
2016
+ self._reset_loop_body_edge_counts(
2017
+ conn, run["id"], resolver, loop_step_id, body_target
2018
+ )
1988
2019
  return body_target
1989
2020
 
1990
2021
  def _resolve_next_in_tx(self, conn, run_id: str, step_id: str,
@@ -2043,7 +2074,19 @@ class SkillFlow:
2043
2074
  tool_node = resolver.get_node(t.to)
2044
2075
  if tool_node and not self._should_delegate_tool(tool_node.tool_name):
2045
2076
  skip_tool = True
2046
- if resolver.is_gate(t.to) or resolver.is_loop(t.to) or skip_tool:
2077
+ if resolver.is_loop(t.to):
2078
+ # PROGRESSION: credit the loop's current_item — but ONLY when
2079
+ # THIS completing step is inside the loop body (a body cycle
2080
+ # returning). An EXTERNAL step transitioning INTO the loop
2081
+ # (entry / goal-loop re-entry) must NOT credit: that would mark
2082
+ # the in-flight item complete and skip it. Topological check
2083
+ # (no config-specific names). Fires once per body cycle; a stray
2084
+ # re-tick at the loop node goes through advance_run only (no
2085
+ # credit), so resolution stays idempotent.
2086
+ if step_id in self._loop_body_nodes(resolver, t.to):
2087
+ self._credit_loop_current_item(conn, run_id, t.to)
2088
+ return None
2089
+ if resolver.is_gate(t.to) or skip_tool:
2047
2090
  return None
2048
2091
 
2049
2092
  # Check max_loop on this edge
@@ -3111,6 +3154,11 @@ class SkillFlow:
3111
3154
  kwargs = dict(params)
3112
3155
  kwargs.setdefault("workspace_root", project_root or "")
3113
3156
  kwargs.setdefault("project_root", project_root or "")
3157
+ # Forward step/run identity so tools that want per-step state (e.g.
3158
+ # scratch-file tools) can isolate by step. Signature-filtered below, so
3159
+ # tools that don't declare these params are unaffected.
3160
+ kwargs.setdefault("step_id", step_id or "")
3161
+ kwargs.setdefault("run_id", run_id or "")
3114
3162
  # SF-10: pass step staging/output dirs so read_file (and similar tools)
3115
3163
  # can find files the agent just wrote (in .tmp) or files from previous
3116
3164
  # retries (in the step's final dir). write_* tools write to .tmp; without
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillflow-py
3
- Version: 1.2.0
3
+ Version: 1.2.1
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:
@@ -511,6 +511,123 @@ def test_loop_flat_list(sf_with_workspace):
511
511
  assert items == ["one", "two"]
512
512
 
513
513
 
514
+ def test_resolve_loop_idempotent_without_body_completion(sf_with_workspace):
515
+ """Re-entering _resolve_loop WITHOUT the body completing must re-pick the
516
+ SAME item and credit nothing — resolution is read-only w.r.t. progression.
517
+
518
+ Regression: the old code credited current_item on every entry, so a stray
519
+ re-resolve in the dispatch->claim gap skipped items and churned the body
520
+ claim (the version-15 / 7-claim behavior in the wild).
521
+ """
522
+ import json
523
+ sf = sf_with_workspace
524
+ run_id = _loop_prepare(sf, [["a", "b", "c"]])
525
+ resolver = sf._get_resolver_for_run(run_id)
526
+
527
+ def _resolve():
528
+ with sf._tx() as conn:
529
+ run = conn.execute("SELECT * FROM skillflow_runs WHERE id = ?",
530
+ (run_id,)).fetchone()
531
+ return sf._resolve_loop(conn, run, resolver, "task_iterator")
532
+
533
+ def _state():
534
+ with sf._tx() as conn:
535
+ r = conn.execute("SELECT completed_items, current_item FROM "
536
+ "skillflow_loop_state WHERE run_id = ?",
537
+ (run_id,)).fetchone()
538
+ return json.loads(r["completed_items"] or "[]"), r["current_item"]
539
+
540
+ t1 = _resolve(); comp1, cur1 = _state()
541
+ t2 = _resolve(); comp2, cur2 = _state() # re-entry, body never ran
542
+ t3 = _resolve(); comp3, cur3 = _state()
543
+ assert t1 == t2 == t3 == "process_task"
544
+ assert cur1 == cur2 == cur3 == "a" # same item every time
545
+ assert comp1 == comp2 == comp3 == [] # nothing credited on resolution
546
+
547
+
548
+ def test_loop_completed_scoped_to_live_manifest(sf_with_workspace):
549
+ """completed_items is intersected with the live manifest on each resolve, so
550
+ superseded names from a prior goal-loop round are dropped (no overcount)."""
551
+ import json
552
+ sf = sf_with_workspace
553
+ run_id = _loop_prepare(sf, [["a", "b"]])
554
+ resolver = sf._get_resolver_for_run(run_id)
555
+ with sf._tx() as conn: # first resolve creates the row + dispatches "a"
556
+ run = conn.execute("SELECT * FROM skillflow_runs WHERE id = ?",
557
+ (run_id,)).fetchone()
558
+ sf._resolve_loop(conn, run, resolver, "task_iterator")
559
+ with sf._tx() as conn: # inject a stale name not in the manifest
560
+ conn.execute("UPDATE skillflow_loop_state SET completed_items = ? "
561
+ "WHERE run_id = ?",
562
+ (json.dumps(["a", "stale_old_task"]), run_id))
563
+ with sf._tx() as conn: # re-resolve: stale dropped, next item is "b"
564
+ run = conn.execute("SELECT * FROM skillflow_runs WHERE id = ?",
565
+ (run_id,)).fetchone()
566
+ nxt = sf._resolve_loop(conn, run, resolver, "task_iterator")
567
+ r = conn.execute("SELECT completed_items, current_item FROM "
568
+ "skillflow_loop_state WHERE run_id = ?", (run_id,)).fetchone()
569
+ assert nxt == "process_task"
570
+ assert "stale_old_task" not in json.loads(r["completed_items"])
571
+ assert r["current_item"] == "b"
572
+
573
+
574
+ def test_loop_progression_credits_on_body_completion(sf_with_workspace):
575
+ """End-to-end: each item is dispatched once, in order, and completed_items
576
+ grows only as body cycles complete (progression lives in confirm_step)."""
577
+ sf = sf_with_workspace
578
+ run_id = _loop_prepare(sf, [["a", "b", "c"]])
579
+ count, items = _drive_loop(sf, run_id)
580
+ assert sf.get_run(run_id)["status"] == "completed"
581
+ assert count == 3
582
+ assert items == ["a", "b", "c"]
583
+
584
+
585
+ def test_loop_body_nodes_excludes_entry_and_loop(sf_with_workspace):
586
+ """_loop_body_nodes is the loop body only — excludes the external entry step
587
+ and the loop node itself (topological, config-agnostic)."""
588
+ sf = sf_with_workspace
589
+ _load_and_register(sf, "loop_step.yaml")
590
+ run_id = sf.create_run("loop_step", project_id="test-loop-bn")
591
+ sf.start_run(run_id)
592
+ resolver = sf._get_resolver_for_run(run_id)
593
+ body = sf._loop_body_nodes(resolver, "task_iterator")
594
+ assert "process_task" in body # body node
595
+ assert "prepare" not in body # external entry — NOT body
596
+ assert "task_iterator" not in body # the loop node itself
597
+
598
+
599
+ def test_loop_credit_only_on_body_return_not_entry(sf_with_workspace):
600
+ """Progression credits current_item only when a BODY step returns to the
601
+ loop. An external step transitioning INTO the loop (entry / goal-loop
602
+ re-entry) must NOT credit — else it marks the in-flight item complete and
603
+ skips it."""
604
+ import json
605
+ sf = sf_with_workspace
606
+ run_id = _loop_prepare(sf, [["alpha", "beta"]])
607
+ resolver = sf._get_resolver_for_run(run_id)
608
+ # First dispatch → current_item = "alpha", nothing credited yet.
609
+ with sf._tx() as conn:
610
+ run = conn.execute("SELECT * FROM skillflow_runs WHERE id = ?",
611
+ (run_id,)).fetchone()
612
+ sf._resolve_loop(conn, run, resolver, "task_iterator")
613
+
614
+ def completed():
615
+ with sf._tx() as conn:
616
+ r = conn.execute("SELECT completed_items FROM skillflow_loop_state "
617
+ "WHERE run_id = ?", (run_id,)).fetchone()
618
+ return json.loads(r["completed_items"] or "[]")
619
+
620
+ assert completed() == []
621
+ # External entry step (prepare) resolving into the loop → MUST NOT credit.
622
+ with sf._tx() as conn:
623
+ sf._resolve_next_in_tx(conn, run_id, "prepare", {}, resolver)
624
+ assert completed() == []
625
+ # Body step (process_task) returning to the loop → credits current_item.
626
+ with sf._tx() as conn:
627
+ sf._resolve_next_in_tx(conn, run_id, "process_task", {}, resolver)
628
+ assert completed() == ["alpha"]
629
+
630
+
514
631
  def test_loop_body_resolves_per_item_file(sf_with_workspace):
515
632
  """Loop body context resolves a per-item file via $current_task — the
516
633
  mechanism dpe uses to scope each task_loop iteration to its task card."""
File without changes
File without changes