Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions dev/state/task-ledger.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"T01_GUARD_CHECK": {
"status": "done",
"evidence": [
"gh pr view 46 --json state,mergedAt -q '.state' => OPEN",
"PR is OPEN — proceeding with analysis"
],
"last_verified": "2026-06-23T00:00:00Z"
},
"T02_RCA_5WHY": {
"status": "done",
"evidence": [
"WHY1: Harness crashes with 'unbound variable' because render.py emits set -euo pipefail (line 26) and references a var that is never defined in harness scope.",
"WHY2: CURRENT_BRANCH is referenced in the harness because re.findall over step.prompt collected it from inside the fenced code block.",
"WHY3: Scanner picked up fenced-block vars because re.findall treated prompt as flat text with no markdown awareness.",
"WHY4: No markdown awareness because expandPrompt was designed only for harness-level params (${ISSUE_NUMBER}) and did not anticipate prompts embedding shell snippets as LLM instructions.",
"WHY5: Edge case not anticipated because original design assumed every $VAR in a prompt was a harness parameter; no test coverage for mixed prompts with code fences.",
"ROOT CAUSE: _render_step_body expandPrompt scanner was context-free — no distinction between text regions and fenced code regions."
],
"last_verified": "2026-06-23T00:01:00Z"
},
"T03_PLAN_FIX": {
"status": "done",
"evidence": [
"Plan: Strip fenced code blocks from step.prompt before scanning for variables.",
"Use re.sub(r'```.*?```', '', step.prompt, flags=re.DOTALL) to produce _prompt_no_code.",
"Run both braced and bare re.findall against _prompt_no_code.",
"Add tests: braced vars in fences NOT expanded, bare vars in fences NOT expanded, vars outside fences still expanded.",
"PR #46 implementation matches plan exactly per diff: render.py +3/-2, tests +59/-0.",
"gh pr view 46 --json mergeable,mergeStateStatus => {mergeable:MERGEABLE, mergeStateStatus:CLEAN}"
],
"last_verified": "2026-06-23T00:02:00Z"
},
"T04_IMPLEMENT": {
"status": "done",
"evidence": [
"Implementation already present at HEAD commit 4840ee1 on branch fix/issue-45-expand-prompt-fenced-code-blocks",
"render.py:329: _prompt_no_code = re.sub(r'```.*?```', '', step.prompt, flags=re.DOTALL)",
"render.py:330-331: braced and bare findall run against _prompt_no_code",
"tests/test_workflow_to_harness.py: 2 new tests added — test_render_harness_expand_prompt_ignores_braced_vars_inside_fenced_code_blocks and test_render_harness_expand_prompt_ignores_bare_vars_inside_fenced_code_blocks",
"uv run --locked pytest => 111 passed in 21.51s",
"make qa => QA passed (ruff check, ruff format, py_compile, pytest, uv build all green)"
],
"last_verified": "2026-06-23T00:03:00Z"
},
"T05_COMMIT_PUSH": {
"status": "in_progress",
"evidence": [],
"last_verified": ""
},
"T06_CI_OBSERVE": {
"status": "pending",
"evidence": [],
"last_verified": ""
}
}
5 changes: 3 additions & 2 deletions src/flowsh_cli/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,8 +326,9 @@ def _render_step_body(step: Step) -> list[str]:
]
)
if step.expandPrompt:
braced = re.findall(r"\$\{([A-Z_][A-Z0-9_]*)\}", step.prompt)
bare = re.findall(r"\$([A-Z_][A-Z0-9_]*)(?!\w)", step.prompt)
_prompt_no_code = re.sub(r"```.*?```", "", step.prompt, flags=re.DOTALL)
braced = re.findall(r"\$\{([A-Z_][A-Z0-9_]*)\}", _prompt_no_code)
bare = re.findall(r"\$([A-Z_][A-Z0-9_]*)(?!\w)", _prompt_no_code)
seen: dict[str, None] = {}
for var in braced + bare:
seen[var] = None
Expand Down
59 changes: 59 additions & 0 deletions tests/test_workflow_to_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,65 @@ def test_render_harness_safe_variable_substitution_when_expand_prompt_enabled()
assert '_p=\'$ISSUE_NUMBER\'; prompt="${prompt//"$_p"/"$ISSUE_NUMBER"}"' in script


def test_render_harness_expand_prompt_ignores_braced_vars_inside_fenced_code_blocks() -> None:
workflow = Workflow(
id="wf_prompt_expand_code_fence",
name="Prompt Expand Code Fence",
steps=[
AgentStep(
type="agent",
prompt=(
"Work on issue ${ISSUE_NUMBER}.\n"
"\n"
"Check for an existing PR:\n"
"\n"
"```bash\n"
"CURRENT_BRANCH=$(git branch --show-current)\n"
'EXISTING_PR=$(gh pr list --head "$CURRENT_BRANCH")\n'
"```\n"
"\n"
"If [EXISTING_PR] is set, skip PR creation."
),
expandPrompt=True,
)
],
)

script = render_harness(workflow)

# ISSUE_NUMBER appears outside the code block — must still be expanded
assert '_p=\'${ISSUE_NUMBER}\'; prompt="${prompt//"$_p"/"$ISSUE_NUMBER"}"' in script
assert '_p=\'$ISSUE_NUMBER\'; prompt="${prompt//"$_p"/"$ISSUE_NUMBER"}"' in script
# CURRENT_BRANCH and EXISTING_PR are inside the code block — must NOT be expanded
assert "_p='${CURRENT_BRANCH}'" not in script
assert "_p='$CURRENT_BRANCH'" not in script
assert "_p='${EXISTING_PR}'" not in script
assert "_p='$EXISTING_PR'" not in script


def test_render_harness_expand_prompt_ignores_bare_vars_inside_fenced_code_blocks() -> None:
workflow = Workflow(
id="wf_prompt_expand_bare_code_fence",
name="Prompt Expand Bare Code Fence",
steps=[
AgentStep(
type="agent",
prompt=("Run the build for $TARGET_ENV.\n\n```bash\n./build.sh $BUILD_FLAGS\n```"),
expandPrompt=True,
)
],
)

script = render_harness(workflow)

# TARGET_ENV is outside the code block — must be expanded
assert '_p=\'${TARGET_ENV}\'; prompt="${prompt//"$_p"/"$TARGET_ENV"}"' in script
assert '_p=\'$TARGET_ENV\'; prompt="${prompt//"$_p"/"$TARGET_ENV"}"' in script
# BUILD_FLAGS is inside the code block — must NOT be expanded
assert "_p='${BUILD_FLAGS}'" not in script
assert "_p='$BUILD_FLAGS'" not in script


def test_render_harness_disambiguates_duplicate_step_function_names(tmp_path: Path) -> None:
workflow_file = tmp_path / "workflows.yml"
workflow_file.write_text(
Expand Down
Loading