skillflow-py 1.4.2__tar.gz → 1.4.4__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.4.2 → skillflow_py-1.4.4}/PKG-INFO +1 -1
  2. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/pyproject.toml +1 -1
  3. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/core.py +42 -23
  4. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/graph.py +9 -0
  5. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/write_tools.py +223 -27
  6. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow_py.egg-info/PKG-INFO +1 -1
  7. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_core.py +51 -0
  8. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_recovery.py +67 -0
  9. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_write_tools.py +172 -2
  10. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/LICENSE +0 -0
  11. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/README.md +0 -0
  12. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/setup.cfg +0 -0
  13. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/__init__.py +0 -0
  14. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/agent_registry.py +0 -0
  15. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/context.py +0 -0
  16. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/convert_cli.py +0 -0
  17. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/exceptions.py +0 -0
  18. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/notifications.py +0 -0
  19. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/outbox.py +0 -0
  20. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/linter/__init__.py +0 -0
  21. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/linter/cli.py +0 -0
  22. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/linter/tools/skillflow_lint/impl.py +0 -0
  23. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/linter/tools/skillflow_lint/tool.yaml +0 -0
  24. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/AGENT.md +0 -0
  25. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/__init__.py +0 -0
  26. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/converter.py +0 -0
  27. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/prompts/analyze_skill.md +0 -0
  28. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/prompts/design_graph.md +0 -0
  29. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/prompts/explain_design.md +0 -0
  30. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/prompts/fix_issues.md +0 -0
  31. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/skill_converter.yaml +0 -0
  32. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_runner/AGENT.md +0 -0
  33. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_runner/__init__.py +0 -0
  34. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_runner/runner.py +0 -0
  35. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/read_tools.py +0 -0
  36. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/recovery.py +0 -0
  37. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/run_cli.py +0 -0
  38. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/schema.py +0 -0
  39. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/step_validation.py +0 -0
  40. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tool_loader.py +0 -0
  41. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/__init__.py +0 -0
  42. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/dir_tree/impl.py +0 -0
  43. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/dir_tree/tool.yaml +0 -0
  44. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/draft_commit/impl.py +0 -0
  45. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/draft_commit/tool.yaml +0 -0
  46. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/file_exists/impl.py +0 -0
  47. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/file_exists/tool.yaml +0 -0
  48. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/git_sync_pre/impl.py +0 -0
  49. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/git_sync_pre/tool.yaml +0 -0
  50. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/json_schema/impl.py +0 -0
  51. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/json_schema/tool.yaml +0 -0
  52. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/lint/impl.py +0 -0
  53. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/lint/tool.yaml +0 -0
  54. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/list_tree/impl.py +0 -0
  55. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/list_tree/tool.yaml +0 -0
  56. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/notify/impl.py +0 -0
  57. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/notify/tool.yaml +0 -0
  58. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/pytest/impl.py +0 -0
  59. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/pytest/tool.yaml +0 -0
  60. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/read_file/impl.py +0 -0
  61. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/read_file/tool.yaml +0 -0
  62. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/repo_apply/impl.py +0 -0
  63. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/repo_apply/tool.yaml +0 -0
  64. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/repo_validate/impl.py +0 -0
  65. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/repo_validate/tool.yaml +0 -0
  66. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/write/impl.py +0 -0
  67. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/write/tool.yaml +0 -0
  68. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/validation.py +0 -0
  69. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/workspace.py +0 -0
  70. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow_py.egg-info/SOURCES.txt +0 -0
  71. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow_py.egg-info/dependency_links.txt +0 -0
  72. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow_py.egg-info/entry_points.txt +0 -0
  73. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow_py.egg-info/requires.txt +0 -0
  74. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow_py.egg-info/top_level.txt +0 -0
  75. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_context.py +0 -0
  76. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_exceptions.py +0 -0
  77. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_existing_repo_apply.py +0 -0
  78. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_git_sync_pre.py +0 -0
  79. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_graph.py +0 -0
  80. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_integration.py +0 -0
  81. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_integration_configs.py +0 -0
  82. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_migration_regression.py +0 -0
  83. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_noop_floor_fix.py +0 -0
  84. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_notifications.py +0 -0
  85. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_outbox.py +0 -0
  86. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_read_tools_exec.py +0 -0
  87. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_read_tools_label.py +0 -0
  88. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_regression.py +0 -0
  89. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_step_validation.py +0 -0
  90. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_tool_loader.py +0 -0
  91. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_tool_lock_contention.py +0 -0
  92. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_tools.py +0 -0
  93. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_validation.py +0 -0
  94. {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillflow-py
3
- Version: 1.4.2
3
+ Version: 1.4.4
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.4.2"
7
+ version = "1.4.4"
8
8
  description = "Config-agnostic LLM pipeline graph executor — turn skills into flows"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1286,29 +1286,44 @@ class SkillFlow:
1286
1286
  hook_name: str, items: list) -> dict:
1287
1287
  """Run a list of tool hooks in order (e.g. repo_apply then repo_delete).
1288
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.
1289
+ Each item is a ``{'tool', 'params', 'on_failure'?, 'max_retries'?}`` dict
1290
+ executed via :meth:`_execute_tool_hook` (full variable resolution +
1291
+ project_root injection). Per-item policy is honored IN PLACE:
1292
+ ``on_failure: retry`` re-runs THAT item up to its own ``max_retries``
1293
+ (without re-running earlier, already-succeeded items or the agent step);
1294
+ ``warn``/``skip`` log and continue to the next item; ``fail`` (or a retry
1295
+ item that exhausts its retries) stops the sequence and fails the step.
1296
+ The sequence never bubbles ``retry`` to the caller — that would reset the
1297
+ whole agent step and re-execute earlier, already-committed items.
1295
1298
  """
1296
1299
  detail_files = None
1297
1300
  for item in items:
1298
1301
  if not (isinstance(item, dict) and "tool" in item):
1299
1302
  return {"passed": False,
1300
1303
  "error": f"Invalid tool hook in '{hook_name}': {item!r}"}
1301
- res = self._execute_tool_hook(token, node, hook_name, item)
1304
+ on_failure = item.get("on_failure", "fail")
1305
+ max_retries = int(item.get("max_retries", 0) or 0)
1306
+ res = {"passed": False}
1307
+ for attempt in range(max_retries + 1):
1308
+ res = self._execute_tool_hook(token, node, hook_name, item)
1309
+ if res.get("passed", True):
1310
+ break
1311
+ if on_failure == "retry" and attempt < max_retries:
1312
+ # Retry THIS item in place — do not re-run earlier items.
1313
+ self._emit_lifecycle_event(
1314
+ token, hook_name, "retry", res.get("error", ""))
1315
+ continue
1316
+ break
1302
1317
  if not res.get("passed", True):
1303
- on_failure = item.get("on_failure", "fail")
1304
1318
  if on_failure in ("warn", "skip"):
1305
1319
  self._emit_lifecycle_event(
1306
1320
  token, hook_name,
1307
1321
  "warned" if on_failure == "warn" else "skipped",
1308
1322
  res.get("error", ""))
1309
1323
  continue
1310
- # retry / fail: stop and let the caller apply the step outcome.
1311
- return {**res, "on_failure": on_failure}
1324
+ # 'fail', or a 'retry' item that exhausted max_retries fail the
1325
+ # step (never bubble 'retry', which re-runs the whole sequence).
1326
+ return {**res, "on_failure": "fail"}
1312
1327
  files = res.get("files")
1313
1328
  if isinstance(files, list):
1314
1329
  detail_files = files
@@ -2326,10 +2341,12 @@ class SkillFlow:
2326
2341
  so it retries promptly instead of stalling until its claim times out.
2327
2342
 
2328
2343
  Capped at 3 crashes: a tool that crashes deterministically is broken,
2329
- not transient, so relaunching it every tick just rampages until the
2330
- host's step-count valve trips. The SF-20 stale-recovery cap does NOT
2331
- cover this path (a crash reopens directly, never via
2332
- recover_stale_claims), so track the count here and fail after 3.
2344
+ not transient. On the 3rd crash the RUN is failed (not just the step)
2345
+ marking only the step failed + current_node=NULL would let advance_run
2346
+ re-resolve back to the tool and open a FRESH instance with a reset
2347
+ counter, so the crash loop never actually stopped (only the host's
2348
+ step-count valve caught it). The SF-20 stale-recovery cap does NOT cover
2349
+ this path (a crash reopens directly, never via recover_stale_claims).
2333
2350
  """
2334
2351
  with self._tx() as conn:
2335
2352
  row = conn.execute(
@@ -2353,11 +2370,9 @@ class SkillFlow:
2353
2370
  "claimed_by = NULL, updated_at = datetime('now') WHERE id = ?",
2354
2371
  (error_msg, row["id"]),
2355
2372
  )
2356
- conn.execute(
2357
- "UPDATE skillflow_runs SET current_node = NULL, "
2358
- "updated_at = datetime('now') WHERE id = ?",
2359
- (run_id,),
2360
- )
2373
+ # Fail the RUN so advance_run returns None instead of re-resolving
2374
+ # the predecessor's transition and opening a fresh tool instance.
2375
+ self._fail_run_in_tx(conn, run_id, error_msg)
2361
2376
  self.notifications.publish_sync(
2362
2377
  "step_failed",
2363
2378
  {"run_id": run_id, "step_id": step_id,
@@ -2984,10 +2999,14 @@ class SkillFlow:
2984
2999
  try:
2985
3000
  node = self._get_resolver_for_run(
2986
3001
  row["run_id"]).get_node(row["step_id"])
2987
- if (node and node.step_type == "tool"
2988
- and node.timeout_seconds
2989
- and node.timeout_seconds > window):
2990
- window = float(node.timeout_seconds)
3002
+ if node and node.step_type == "tool":
3003
+ if node.timeout_seconds == 0:
3004
+ # 0 = "no timeout": a live tool may run arbitrarily
3005
+ # long, so it is NEVER stale — reclaiming it would
3006
+ # relaunch it concurrently with itself (the rampage).
3007
+ continue
3008
+ if node.timeout_seconds > window:
3009
+ window = float(node.timeout_seconds)
2991
3010
  except Exception:
2992
3011
  pass
2993
3012
  threshold = time.strftime(
@@ -850,6 +850,15 @@ def _flags_match(match: dict, flags: dict, *,
850
850
  return False
851
851
  data = _parse_json_extract_last(content)
852
852
  if data is None:
853
+ # The file exists but no JSON value could be parsed out of it
854
+ # (e.g. an invalid \escape written by an agent). Without this
855
+ # log the run dies on "No matching transition ... flags {}"
856
+ # with no hint that the real problem is a syntax error.
857
+ import logging
858
+ logging.getLogger("skillflow.graph").warning(
859
+ "from_file match: '%s' exists but contains no parseable "
860
+ "JSON — transition treated as unmatched",
861
+ match["from_file"])
853
862
  return False
854
863
  return data.get(match["field"]) == match["value"]
855
864
 
@@ -25,6 +25,7 @@ from __future__ import annotations
25
25
 
26
26
  import json
27
27
  import os
28
+ import re
28
29
  from pathlib import Path
29
30
 
30
31
 
@@ -38,6 +39,95 @@ def _ensure_str(value, default: str = "") -> str:
38
39
  return json.dumps(value, ensure_ascii=False)
39
40
 
40
41
 
42
+ # ── Structured JSON slots ────────────────────────────────────────────
43
+ # A model asked to author a JSON document as a STRING inside tool-call JSON
44
+ # must escape every backslash twice; under-escaping by one level (e.g. a
45
+ # regex `/\.0$/` quoted in review feedback) produces a file that json.loads
46
+ # rejects — and the run dies on an unmatched transition. For .json slots we
47
+ # therefore move the document INTO the tool arguments, where the provider's
48
+ # constrained decoding guarantees validity:
49
+ # tier 1 — the slot's `format` spec parses → one argument per field;
50
+ # tier 2 — no parseable format → a single `content` argument typed OBJECT;
51
+ # tier 3 — a string is still accepted but json.loads-validated at write
52
+ # time, returning an actionable tool error instead of writing a
53
+ # file no reader can parse.
54
+
55
+ _TYPE_TOKENS = {
56
+ "bool": {"type": "boolean"},
57
+ "str": {"type": "string"},
58
+ "int": {"type": "integer"},
59
+ "float": {"type": "number"},
60
+ "num": {"type": "number"},
61
+ }
62
+
63
+
64
+ def _is_json_file(pattern: str) -> bool:
65
+ return pattern.lower().endswith(".json")
66
+
67
+
68
+ def _format_value_to_spec(value) -> dict | None:
69
+ """Convert one parsed format value into a parameter spec, or None."""
70
+ if isinstance(value, str):
71
+ token = value[2:-2] if value.startswith("__") and value.endswith("__") else None
72
+ if token in _TYPE_TOKENS:
73
+ return dict(_TYPE_TOKENS[token])
74
+ # Free-text value = a string field described by that text
75
+ # (e.g. "description": "ONE-LINE summary (max 80 chars)").
76
+ return {"type": "string", "description": value}
77
+ if isinstance(value, list):
78
+ if len(value) != 1:
79
+ return None
80
+ items = _format_value_to_spec(value[0])
81
+ if items is None:
82
+ return None
83
+ return {"type": "array", "items": items}
84
+ if isinstance(value, dict):
85
+ props = {}
86
+ for k, v in value.items():
87
+ spec = _format_value_to_spec(v)
88
+ if spec is None:
89
+ return None
90
+ props[k] = spec
91
+ return {"type": "object", "properties": props}
92
+ return None
93
+
94
+
95
+ def _parse_format_spec(format_spec) -> dict | None:
96
+ """Parse a pseudo-JSON ``format`` spec into per-field parameter specs.
97
+
98
+ ``'{"passed": bool, "feedback": str, "suggestions": [str, ...]}'``
99
+ → ``{"passed": {"type": "boolean"}, "feedback": {"type": "string"},
100
+ "suggestions": {"type": "array", "items": {"type": "string"}}}``
101
+
102
+ Returns None when the spec is absent or uses constructs the mini-DSL
103
+ can't resolve (e.g. a named shape like ``[subtask, ...]``) — callers
104
+ then fall back to a whole-document object parameter.
105
+ """
106
+ if not format_spec or not isinstance(format_spec, str):
107
+ return None
108
+ spec = format_spec.strip()
109
+ if not spec.startswith("{"):
110
+ return None
111
+ # ", ..." ellipses are illustrative — drop them
112
+ normalized = re.sub(r",\s*\.\.\.", "", spec)
113
+ # quote bare type tokens so the spec becomes valid JSON
114
+ normalized = re.sub(
115
+ r"\b(bool|str|int|float|num)\b", r'"__\1__"', normalized)
116
+ try:
117
+ data = json.loads(normalized)
118
+ except (json.JSONDecodeError, ValueError):
119
+ return None
120
+ if not isinstance(data, dict) or not data:
121
+ return None
122
+ props = {}
123
+ for key, value in data.items():
124
+ spec_v = _format_value_to_spec(value)
125
+ if spec_v is None:
126
+ return None
127
+ props[key] = spec_v
128
+ return props
129
+
130
+
41
131
  def _normalize_fixed_entry(value) -> dict:
42
132
  """Normalize a fixed output entry to {file, on_exists, format} dict."""
43
133
  if isinstance(value, str):
@@ -165,30 +255,71 @@ def generate_write_tool_schemas(output_mode: str,
165
255
  fmt_hint = f"\nExpected format: {format_spec.strip()}" if format_spec else ""
166
256
  is_glob = "*" in pattern
167
257
 
168
- # Shared params
169
- if is_glob:
170
- id_desc = f"Replaces * in {pattern}"
171
- # When the format declares an "id" field, the parameter value
172
- # should match it otherwise the LLM fills in a placeholder
173
- # like "unknown" and all task cards land in the same file.
174
- if format_spec and '"id"' in format_spec:
175
- id_desc += " — must equal the 'id' field value in your file content"
176
- params = {
177
- "id": {"type": "string", "required": True,
178
- "description": id_desc},
179
- "content": {"type": "string", "required": True},
258
+ field_props = (_parse_format_spec(format_spec)
259
+ if _is_json_file(pattern) else None)
260
+
261
+ if field_props:
262
+ # Tier 1one argument per document field: the model authors
263
+ # no JSON-in-a-string, so backslashes in field text can never
264
+ # produce an unparseable file.
265
+ params = {}
266
+ if is_glob and "id" not in field_props:
267
+ params["id"] = {"type": "string", "required": True,
268
+ "description": f"Replaces * in {pattern}"}
269
+ for fname, fspec in field_props.items():
270
+ p = dict(fspec)
271
+ p["required"] = True
272
+ if is_glob and fname == "id":
273
+ extra = f"Replaces * in {pattern}; also written as the 'id' field."
274
+ p["description"] = (
275
+ f"{p.get('description', '')} {extra}".strip())
276
+ params[fname] = p
277
+ create_params = dict(params)
278
+ write_hint = (fmt_hint + "\nProvide one argument per field — "
279
+ "do NOT pass the document as a JSON-encoded string.")
280
+ elif _is_json_file(pattern):
281
+ # Tier 2 — whole document as a structured OBJECT argument
282
+ # (format absent or beyond the mini-DSL).
283
+ content_spec = {
284
+ "type": "object", "required": True,
285
+ "description": ("The complete JSON document as a structured "
286
+ "object — NOT a JSON-encoded string."),
180
287
  }
288
+ if is_glob:
289
+ id_desc = f"Replaces * in {pattern}"
290
+ if format_spec and '"id"' in format_spec:
291
+ id_desc += " — must equal the 'id' field value in your document"
292
+ params = {
293
+ "id": {"type": "string", "required": True,
294
+ "description": id_desc},
295
+ "content": content_spec,
296
+ }
297
+ else:
298
+ params = {"content": content_spec}
299
+ create_params = dict(params)
300
+ create_params["initialContent"] = create_params.pop("content")
301
+ write_hint = fmt_hint
181
302
  else:
182
- params = {"content": {"type": "string", "required": True}}
183
-
184
- create_params = dict(params)
185
- if "content" in create_params:
303
+ # Text slots (md etc.) — plain string content, unchanged.
304
+ if is_glob:
305
+ id_desc = f"Replaces * in {pattern}"
306
+ if format_spec and '"id"' in format_spec:
307
+ id_desc += " — must equal the 'id' field value in your file content"
308
+ params = {
309
+ "id": {"type": "string", "required": True,
310
+ "description": id_desc},
311
+ "content": {"type": "string", "required": True},
312
+ }
313
+ else:
314
+ params = {"content": {"type": "string", "required": True}}
315
+ create_params = dict(params)
186
316
  create_params["initialContent"] = create_params.pop("content")
317
+ write_hint = fmt_hint
187
318
 
188
319
  # write_{slot} — full replace
189
320
  tools.append({
190
321
  "name": f"write_{slot}",
191
- "description": f"Replace {pattern} with new content.{fmt_hint}",
322
+ "description": f"Replace {pattern} with new content.{write_hint}",
192
323
  "parameters": params,
193
324
  })
194
325
 
@@ -198,7 +329,7 @@ def generate_write_tool_schemas(output_mode: str,
198
329
  "description": (
199
330
  f"Create {pattern} with initial content. "
200
331
  f"If file already exists, it is archived with a numeric suffix, "
201
- f"so {pattern} always holds the latest version.{fmt_hint}"
332
+ f"so {pattern} always holds the latest version.{write_hint}"
202
333
  ),
203
334
  "parameters": create_params,
204
335
  })
@@ -241,6 +372,62 @@ def generate_write_tool_schemas(output_mode: str,
241
372
  return []
242
373
 
243
374
 
375
+ def _resolve_slot_document(slot: str, fixed: dict, params: dict) -> "tuple[str | None, dict | None]":
376
+ """Resolve the text to write for a write_/create_{slot} call.
377
+
378
+ Returns ``(text, None)`` on success or ``(None, error_dict)`` — the error
379
+ goes back to the agent as the tool result, so it can self-correct within
380
+ the same step instead of a downstream reader choking on a bad file.
381
+
382
+ JSON slots accept three input shapes: per-field arguments (tier 1),
383
+ a structured object in content/initialContent (tier 2), or a raw string
384
+ that must survive ``json.loads`` (tier 3 — the legacy path that used to
385
+ write invalid escapes straight to disk). Non-JSON slots keep the legacy
386
+ string behaviour verbatim.
387
+ """
388
+ entry = fixed.get(slot)
389
+ normalized = _normalize_fixed_entry(entry) if entry is not None else {"file": ""}
390
+ pattern = normalized.get("file", "")
391
+
392
+ raw = params.get("content")
393
+ if raw is None or raw == "":
394
+ raw = params.get("initialContent")
395
+
396
+ if not _is_json_file(pattern):
397
+ return _ensure_str(raw, ""), None
398
+
399
+ field_props = _parse_format_spec(normalized.get("format"))
400
+ if field_props and raw is None:
401
+ doc, missing = {}, []
402
+ for fname in field_props:
403
+ if fname in params:
404
+ doc[fname] = params[fname]
405
+ else:
406
+ missing.append(fname)
407
+ if missing:
408
+ return None, {"error": (
409
+ f"{slot}: missing required field argument(s): "
410
+ f"{', '.join(missing)} — provide one argument per field "
411
+ f"of the document.")}
412
+ return json.dumps(doc, indent=2, ensure_ascii=False), None
413
+
414
+ if isinstance(raw, (dict, list)):
415
+ return json.dumps(raw, indent=2, ensure_ascii=False), None
416
+
417
+ if isinstance(raw, str) and raw.strip():
418
+ try:
419
+ json.loads(raw)
420
+ except json.JSONDecodeError as e:
421
+ return None, {"error": (
422
+ f"content for '{pattern}' is not valid JSON: {e}. Pass the "
423
+ f"document as structured arguments (or a plain object), not a "
424
+ f"JSON-encoded string; if you must pass a string, double every "
425
+ f"backslash (\\\\).")}
426
+ return raw, None
427
+
428
+ return None, {"error": f"{slot}: no content provided"}
429
+
430
+
244
431
  def resolve_write_target(slot: str, fixed: dict, params: dict) -> str:
245
432
  """Resolve the actual filename to write for a given write_* call."""
246
433
  entry = fixed.get(slot)
@@ -260,23 +447,27 @@ def execute_write(slot: str, fixed: dict, params: dict,
260
447
  directory = Path(output_dir)
261
448
  directory.mkdir(parents=True, exist_ok=True)
262
449
 
263
- if mode == "new":
264
- # Flipped rename: archive old file, write new content to canonical name
265
- archived = _archive_old_file(directory, base_name)
266
- filename = base_name
267
- elif mode == "append":
450
+ if mode == "append":
268
451
  filename = base_name
269
452
  path = directory / filename
270
453
  content = _ensure_str(params.get("content", ""))
271
454
  existing = path.read_text(encoding="utf-8") if path.exists() else ""
272
455
  path.write_text(existing + content, encoding="utf-8")
273
456
  return {"written": filename}
274
- else: # replace
275
- filename = base_name
276
457
 
458
+ # Resolve (and validate) the document BEFORE any archive rename, so a
459
+ # rejected write leaves the existing canonical file untouched.
460
+ content, err = _resolve_slot_document(slot, fixed, params)
461
+ if err:
462
+ return err
463
+
464
+ archived = None
465
+ if mode == "new":
466
+ # Flipped rename: archive old file, write new content to canonical name
467
+ archived = _archive_old_file(directory, base_name)
468
+ filename = base_name
277
469
  path = directory / filename
278
470
  path.parent.mkdir(parents=True, exist_ok=True)
279
- content = _ensure_str(params.get("content") or params.get("initialContent", ""))
280
471
  path.write_text(content, encoding="utf-8")
281
472
  result = {"written": filename}
282
473
  if mode == "new" and archived:
@@ -290,10 +481,15 @@ def execute_create(slot: str, fixed: dict, params: dict,
290
481
  base_name = resolve_write_target(slot, fixed, params)
291
482
  directory = Path(output_dir)
292
483
  directory.mkdir(parents=True, exist_ok=True)
484
+ # Resolve (and validate) BEFORE archiving so a rejected write leaves the
485
+ # existing canonical file untouched.
486
+ content, err = _resolve_slot_document(slot, fixed, params)
487
+ if err:
488
+ return err
293
489
  archived = _archive_old_file(directory, base_name)
294
490
  path = directory / base_name
295
491
  path.parent.mkdir(parents=True, exist_ok=True)
296
- path.write_text(_ensure_str(params.get("content") or params.get("initialContent", "")), encoding="utf-8")
492
+ path.write_text(content, encoding="utf-8")
297
493
  result = {"written": base_name}
298
494
  if archived:
299
495
  result["archived"] = archived
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillflow-py
3
- Version: 1.4.2
3
+ Version: 1.4.4
4
4
  Summary: Config-agnostic LLM pipeline graph executor — turn skills into flows
5
5
  Author: Skillflow contributors
6
6
  License-Expression: MIT
@@ -1247,6 +1247,57 @@ def test_on_deliver_list_order_and_on_failure(sf: SkillFlow, tmp_path: Path,
1247
1247
  assert calls == []
1248
1248
 
1249
1249
 
1250
+ def test_on_deliver_item_retries_in_place(sf: SkillFlow, tmp_path: Path,
1251
+ monkeypatch):
1252
+ """A failing on_deliver item with on_failure=retry re-runs THAT item up to
1253
+ its own max_retries IN PLACE; earlier items are not re-run, and the sequence
1254
+ never bubbles 'retry' to the caller (which would reset the whole step)."""
1255
+ sf._tool_loader = ToolLoader(
1256
+ Path(__file__).parent.parent / "src" / "skillflow" / "tools")
1257
+ sf._workspace = WorkspaceManager(str(tmp_path / "ws"),
1258
+ projects_base=str(tmp_path / "proj"))
1259
+ node = StepNode(id="s1", step_type="agent", output_mode="content",
1260
+ output_fixed={"out": "r.md"}, transitions=[Transition(to=None)])
1261
+ sf.register_graph(PipelineGraph(name="seq", begin="s1", steps=[node]))
1262
+ rid = sf.create_run("seq", {"project_id": "pid"})
1263
+ sf.start_run(rid)
1264
+ sf.advance_run(rid)
1265
+ token = sf.claim_next_step(rid).token
1266
+
1267
+ calls: list = []
1268
+
1269
+ # 'flaky' fails its first 2 attempts, succeeds on the 3rd.
1270
+ def fake(tok, nd, hook_name, spec):
1271
+ calls.append(spec["tool"])
1272
+ if spec["tool"] == "flaky":
1273
+ return {"passed": calls.count("flaky") >= 3}
1274
+ return {"passed": True}
1275
+ monkeypatch.setattr(sf, "_execute_tool_hook", fake)
1276
+
1277
+ r = sf._execute_lifecycle_hook(token, node, "on_deliver", [
1278
+ {"tool": "repo_apply"},
1279
+ {"tool": "flaky", "on_failure": "retry", "max_retries": 3},
1280
+ ])
1281
+ # repo_apply ran ONCE (not re-run while flaky retried); flaky retried in place.
1282
+ assert calls == ["repo_apply", "flaky", "flaky", "flaky"]
1283
+ assert r["passed"] is True
1284
+
1285
+ # Exhausted retries → fail the STEP, never bubble 'retry'.
1286
+ calls.clear()
1287
+
1288
+ def always_fail(tok, nd, hook_name, spec):
1289
+ calls.append(spec["tool"])
1290
+ return {"passed": spec["tool"] != "boom"}
1291
+ monkeypatch.setattr(sf, "_execute_tool_hook", always_fail)
1292
+ r = sf._execute_lifecycle_hook(token, node, "on_deliver", [
1293
+ {"tool": "repo_apply"},
1294
+ {"tool": "boom", "on_failure": "retry", "max_retries": 2},
1295
+ ])
1296
+ assert calls == ["repo_apply", "boom", "boom", "boom"] # 1 initial + 2 retries
1297
+ assert r["passed"] is False
1298
+ assert r.get("on_failure") == "fail" # NOT "retry" — no whole-step re-run
1299
+
1300
+
1250
1301
  # ── Durable run trace ─────────────────────────────────────────────────
1251
1302
 
1252
1303
  def test_trace_append_and_get(sf: SkillFlow):
@@ -1,17 +1,84 @@
1
1
  """Unit tests for recovery.py."""
2
2
 
3
3
  import time
4
+ from pathlib import Path
4
5
 
5
6
  import pytest
6
7
 
7
8
  from skillflow.core import SkillFlow
8
9
  from skillflow.graph import PipelineGraph, StepNode, Transition
10
+ from skillflow.tool_loader import ToolLoader
11
+ from skillflow.workspace import WorkspaceManager
12
+
13
+ _REAL_TOOLS = Path(__file__).parent.parent / "src" / "skillflow" / "tools"
9
14
 
10
15
 
11
16
  def _agent(id: str, transitions=None):
12
17
  return StepNode(id=id, step_type="agent", transitions=transitions or [])
13
18
 
14
19
 
20
+ def test_recover_never_reclaims_tool_with_zero_timeout(sf: SkillFlow):
21
+ """A tool node with timeout_seconds=0 ('no timeout') is never reclaimed,
22
+ however old the claim — reclaiming a live no-timeout tool would relaunch it
23
+ concurrently with itself (the rampage)."""
24
+ graph = PipelineGraph(
25
+ name="test", begin="a",
26
+ steps=[StepNode(id="a", step_type="tool", tool_name="write",
27
+ timeout_seconds=0, transitions=[])],
28
+ )
29
+ sf.register_graph(graph)
30
+ run_id = sf.create_run("test")
31
+ sf.start_run(run_id)
32
+ long_ago = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 99999))
33
+ with sf._lock:
34
+ sf._conn.execute(
35
+ "INSERT INTO skillflow_steps (run_id, step_id, step_config_json, "
36
+ "inputs_json, max_retries, status, claimed_at, created_at, updated_at) "
37
+ "VALUES (?, 'a', '{}', '{}', 3, 'claimed', ?, "
38
+ "datetime('now'), datetime('now'))",
39
+ (run_id, long_ago))
40
+ sf._conn.commit()
41
+ # Claimed 99999s ago, but timeout_seconds=0 → never stale.
42
+ assert sf.recover_stale_claims(stale_threshold_seconds=60) == []
43
+ st = sf._conn.execute(
44
+ "SELECT status FROM skillflow_steps WHERE run_id=? AND step_id='a' "
45
+ "ORDER BY id DESC LIMIT 1", (run_id,)).fetchone()
46
+ assert st["status"] == "claimed"
47
+
48
+
49
+ def test_tool_crash_fails_run_after_three(sf: SkillFlow, tmp_path,
50
+ monkeypatch):
51
+ """A tool that crashes on EVERY execution fails the RUN after 3 crashes,
52
+ instead of looping forever via advance_run re-resolving into fresh instances
53
+ (regression: the per-instance _tool_reopen_count was reset each time
54
+ _claim_tool_step_in_tx opened a new instance)."""
55
+ sf._tool_loader = ToolLoader(_REAL_TOOLS)
56
+ sf._workspace = WorkspaceManager(str(tmp_path / "ws"),
57
+ projects_base=str(tmp_path / "proj"))
58
+ node = StepNode(id="boom", step_type="tool", tool_name="write",
59
+ transitions=[Transition(to=None)])
60
+ sf.register_graph(PipelineGraph(name="crash", begin="boom", steps=[node]))
61
+ run_id = sf.create_run("crash", {"project_id": "p"})
62
+ sf.start_run(run_id)
63
+
64
+ def _boom(*a, **k):
65
+ raise RuntimeError("boom")
66
+ monkeypatch.setattr(sf, "_execute_tool_inline", _boom)
67
+
68
+ for _ in range(25):
69
+ try:
70
+ sf.advance_run(run_id) # tool fast-path reopens then re-raises
71
+ except RuntimeError:
72
+ pass
73
+ if sf.get_run(run_id)["status"] == "failed":
74
+ break
75
+ assert sf.get_run(run_id)["status"] == "failed"
76
+ n = sf._conn.execute(
77
+ "SELECT COUNT(*) c FROM skillflow_steps WHERE run_id=? AND step_id='boom'",
78
+ (run_id,)).fetchone()["c"]
79
+ assert n == 1, f"crash should fail the run after 1 instance x3, got {n}"
80
+
81
+
15
82
  def test_recover_respects_tool_timeout_seconds(sf: SkillFlow):
16
83
  """A TOOL step claimed longer than the flat threshold but WITHIN its
17
84
  timeout_seconds is NOT reclaimed — a slow-but-alive tool (run_tests,
@@ -232,11 +232,12 @@ class TestWriteTools:
232
232
  desc = tool["description"]
233
233
  assert "Expected format:" in desc
234
234
  assert '{"id": str, "description": str}' in desc
235
- # id param description links to content's id field when format has "id"
235
+ # Structured slot: the id argument IS the content's id field the
236
+ # coupling is structural now, not a prompt-hint plea.
236
237
  write_tool = next(t for t in schemas if t["name"] == "write_task_card")
237
238
  id_desc = write_tool["parameters"]["id"]["description"]
238
239
  assert "Replaces * in tasks/*.json" in id_desc
239
- assert "must equal the 'id' field value" in id_desc
240
+ assert "also written as the 'id' field" in id_desc
240
241
 
241
242
  def test_glob_id_param_without_id_in_format(self):
242
243
  """When format lacks 'id', the id param description stays plain."""
@@ -257,3 +258,172 @@ class TestWriteTools:
257
258
  # String entry omits format
258
259
  result = _normalize_fixed_entry("x.json")
259
260
  assert "format" not in result
261
+
262
+
263
+ VERDICT_FIXED = {
264
+ "verdict": {"file": "review_verdict.json", "on_exists": "new",
265
+ "format": '{"passed": bool, "feedback": str, "suggestions": [str, ...]}'}
266
+ }
267
+
268
+
269
+ class TestStructuredJsonSlots:
270
+ """Structured .json slots: the document rides inside the tool arguments,
271
+ so JSON-in-a-string double-escaping (the review_verdict '\\.' incident)
272
+ is structurally impossible — and a string fallback is validated."""
273
+
274
+ # ── format-spec parser ──────────────────────────────────────
275
+
276
+ def test_parse_format_verdict(self):
277
+ from skillflow.write_tools import _parse_format_spec
278
+ props = _parse_format_spec(
279
+ '{"passed": bool, "feedback": str, "suggestions": [str, ...]}')
280
+ assert props == {
281
+ "passed": {"type": "boolean"},
282
+ "feedback": {"type": "string"},
283
+ "suggestions": {"type": "array", "items": {"type": "string"}},
284
+ }
285
+
286
+ def test_parse_format_nested_arrays(self):
287
+ from skillflow.write_tools import _parse_format_spec
288
+ props = _parse_format_spec('{"execution_order": [[str, ...], ...]}')
289
+ assert props == {"execution_order": {
290
+ "type": "array", "items": {"type": "array",
291
+ "items": {"type": "string"}}}}
292
+
293
+ def test_parse_format_prose_becomes_description(self):
294
+ from skillflow.write_tools import _parse_format_spec
295
+ props = _parse_format_spec(
296
+ '{"id": str, "description": "ONE-LINE summary (max 80 chars)"}')
297
+ assert props["description"] == {
298
+ "type": "string",
299
+ "description": "ONE-LINE summary (max 80 chars)"}
300
+
301
+ def test_parse_format_named_shape_unparseable(self):
302
+ from skillflow.write_tools import _parse_format_spec
303
+ # References a named shape the mini-DSL can't resolve → None (tier 2)
304
+ assert _parse_format_spec(
305
+ '{"subtasks": [subtask, ...], "execution_order": [[str, ...], ...]}'
306
+ ) is None
307
+
308
+ def test_parse_format_non_object(self):
309
+ from skillflow.write_tools import _parse_format_spec
310
+ assert _parse_format_spec("free prose format") is None
311
+ assert _parse_format_spec(None) is None
312
+ assert _parse_format_spec("") is None
313
+
314
+ # ── schema generation tiers ─────────────────────────────────
315
+
316
+ def test_tier1_per_field_params(self):
317
+ schemas = generate_write_tool_schemas("content", VERDICT_FIXED)
318
+ create = next(s for s in schemas if s["name"] == "create_verdict")
319
+ assert set(create["parameters"]) == {"passed", "feedback", "suggestions"}
320
+ assert create["parameters"]["passed"] == {
321
+ "type": "boolean", "required": True}
322
+ assert create["parameters"]["suggestions"]["items"] == {"type": "string"}
323
+ # no stringly content params anywhere
324
+ assert "content" not in create["parameters"]
325
+ assert "initialContent" not in create["parameters"]
326
+ assert "do NOT pass the document as a JSON-encoded string" in create["description"]
327
+ # edit stays textual (surgical string replace)
328
+ edit = next(s for s in schemas if s["name"] == "edit_verdict")
329
+ assert set(edit["parameters"]) == {"old_str", "new_str"}
330
+
331
+ def test_tier2_object_content_when_format_unparseable(self):
332
+ schemas = generate_write_tool_schemas("content", {
333
+ "manifest": {"file": "subtasks_manifest.json",
334
+ "format": '{"subtasks": [subtask, ...]}'}
335
+ })
336
+ write = next(s for s in schemas if s["name"] == "write_manifest")
337
+ assert write["parameters"]["content"]["type"] == "object"
338
+ create = next(s for s in schemas if s["name"] == "create_manifest")
339
+ assert create["parameters"]["initialContent"]["type"] == "object"
340
+
341
+ def test_tier3_text_slot_unchanged(self):
342
+ schemas = generate_write_tool_schemas("content", {
343
+ "notes": {"file": "research_notes.md", "format": "prose format"}
344
+ })
345
+ write = next(s for s in schemas if s["name"] == "write_notes")
346
+ assert write["parameters"]["content"] == {"type": "string",
347
+ "required": True}
348
+
349
+ # ── executor: per-field assembly ────────────────────────────
350
+
351
+ def test_create_assembles_document_from_fields(self, tmp_path):
352
+ from skillflow.write_tools import execute_create
353
+ import json as _json
354
+ res = execute_create("verdict", VERDICT_FIXED, {
355
+ "passed": True,
356
+ "feedback": r"the `.replace(/\.0$/, '')` fix is correct",
357
+ "suggestions": [],
358
+ }, str(tmp_path))
359
+ assert res == {"written": "review_verdict.json"}
360
+ data = _json.loads((tmp_path / "review_verdict.json").read_text())
361
+ # The backslash that used to kill the run survives intact
362
+ assert data["passed"] is True
363
+ assert "\\.0$" in data["feedback"]
364
+
365
+ def test_create_missing_field_is_tool_error(self, tmp_path):
366
+ from skillflow.write_tools import execute_create
367
+ res = execute_create("verdict", VERDICT_FIXED,
368
+ {"passed": True}, str(tmp_path))
369
+ assert "missing required field" in res["error"]
370
+ assert not (tmp_path / "review_verdict.json").exists()
371
+
372
+ def test_create_accepts_object_content(self, tmp_path):
373
+ from skillflow.write_tools import execute_create
374
+ import json as _json
375
+ res = execute_create("verdict", VERDICT_FIXED, {
376
+ "initialContent": {"passed": False, "feedback": "x",
377
+ "suggestions": ["y"]},
378
+ }, str(tmp_path))
379
+ assert res == {"written": "review_verdict.json"}
380
+ data = _json.loads((tmp_path / "review_verdict.json").read_text())
381
+ assert data["passed"] is False
382
+
383
+ def test_create_validates_string_content(self, tmp_path):
384
+ from skillflow.write_tools import execute_create
385
+ # The exact incident: an under-escaped backslash in a JSON string
386
+ bad = '{"passed": true, "feedback": "regex /\\.0$/ ok", "suggestions": []}'
387
+ assert "\\." in bad # single backslash-dot → invalid JSON escape
388
+ res = execute_create("verdict", VERDICT_FIXED,
389
+ {"initialContent": bad}, str(tmp_path))
390
+ assert "not valid JSON" in res["error"]
391
+ assert not (tmp_path / "review_verdict.json").exists()
392
+
393
+ def test_create_valid_string_still_accepted(self, tmp_path):
394
+ from skillflow.write_tools import execute_create
395
+ good = '{"passed": true, "feedback": "ok", "suggestions": []}'
396
+ res = execute_create("verdict", VERDICT_FIXED,
397
+ {"initialContent": good}, str(tmp_path))
398
+ assert res == {"written": "review_verdict.json"}
399
+
400
+ def test_rejected_write_preserves_existing_file(self, tmp_path):
401
+ from skillflow.write_tools import execute_create
402
+ (tmp_path / "review_verdict.json").write_text('{"passed": false}')
403
+ res = execute_create("verdict", VERDICT_FIXED,
404
+ {"initialContent": "{invalid"}, str(tmp_path))
405
+ assert "error" in res
406
+ # on_exists:new must NOT have archived the old file on a rejected write
407
+ assert (tmp_path / "review_verdict.json").read_text() == '{"passed": false}'
408
+ assert not (tmp_path / "review_verdict_1.json").exists()
409
+
410
+ def test_glob_id_fills_filename_and_field(self, tmp_path):
411
+ from skillflow.write_tools import execute_write
412
+ import json as _json
413
+ fixed = {"task_card": {"file": "tasks/*.json",
414
+ "format": '{"id": str, "description": str}'}}
415
+ res = execute_write("task_card", fixed, {
416
+ "id": "core_lib", "description": "build the core library",
417
+ }, str(tmp_path))
418
+ assert res == {"written": "tasks/core_lib.json"}
419
+ data = _json.loads((tmp_path / "tasks/core_lib.json").read_text())
420
+ assert data == {"id": "core_lib",
421
+ "description": "build the core library"}
422
+
423
+ def test_text_slot_write_unchanged(self, tmp_path):
424
+ from skillflow.write_tools import execute_write
425
+ res = execute_write("notes", {"notes": "research_notes.md"},
426
+ {"content": "plain text with \\. backslash"},
427
+ str(tmp_path))
428
+ assert res == {"written": "research_notes.md"}
429
+ assert "\\." in (tmp_path / "research_notes.md").read_text()
File without changes
File without changes
File without changes