Skip to content
This repository was archived by the owner on Jul 13, 2026. It is now read-only.

Commit 087e281

Browse files
committed
fix: address PR comment edge cases
1 parent b634aea commit 087e281

13 files changed

Lines changed: 442 additions & 36 deletions

skills/bmad-story-automator/src/story_automator/commands/orchestrator_parse.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ def parse_output_action(args: list[str]) -> int:
6969
)
7070
if result.exit_code != 0:
7171
reason = "sub-agent call timed out" if result.exit_code == COMMAND_TIMEOUT_EXIT else "sub-agent call failed"
72-
issues = issues_from_exception(result.error or RuntimeError(reason), source="parse-output", field="sub_agent")
72+
error = result.error if isinstance(result.error, Exception) else RuntimeError(str(result.error or reason))
73+
issues = issues_from_exception(error, source="parse-output", field="sub_agent")
7374
_emit_parse_event("orchestration.stage.result", step, reason, severity="error", issues=issues)
7475
print_json(parse_failure_payload(reason, issues))
7576
return 1

skills/bmad-story-automator/src/story_automator/commands/orchestrator_state.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import json
44
import re
55

6-
from story_automator.core.frontmatter import parse_frontmatter_content
6+
from story_automator.core.frontmatter import frontmatter_content, parse_frontmatter_content, split_frontmatter_document
77
from story_automator.core.diagnostics import (
88
issues_from_exception,
99
legacy_issue_message,
@@ -133,6 +133,8 @@ def _render_frontmatter_value(key: str, value: str) -> str:
133133
value != stripped
134134
or lower in {"true", "false", "null"}
135135
or re.fullmatch(r"0[0-9]+", stripped)
136+
or re.fullmatch(r"[-+]?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?", stripped)
137+
or stripped.startswith(("[", "{"))
136138
or "# " in stripped
137139
or stripped.startswith("#")
138140
or ": " in stripped
@@ -142,16 +144,8 @@ def _render_frontmatter_value(key: str, value: str) -> str:
142144

143145

144146
def _split_frontmatter(text: str) -> tuple[str, str]:
145-
if not text.startswith("---"):
146-
return "", text
147-
parts = text.split("---", 2)
148-
if len(parts) < 3:
149-
return "", text
150-
return f"{parts[0]}---{parts[1]}---", parts[2]
147+
return split_frontmatter_document(text)
151148

152149

153150
def _frontmatter_content(frontmatter: str) -> str:
154-
if not frontmatter.startswith("---"):
155-
return frontmatter
156-
parts = frontmatter.split("---", 2)
157-
return parts[1] if len(parts) >= 3 else frontmatter
151+
return frontmatter_content(frontmatter)

skills/bmad-story-automator/src/story_automator/core/agent_config.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from .agent_config_frontmatter import extract_agent_config_frontmatter
99
from .common import ensure_dir, file_exists, read_text, write_atomic
10-
from .frontmatter import extract_frontmatter
10+
from .frontmatter import extract_frontmatter, split_frontmatter_document
1111
from .runtime_layout import runtime_provider
1212

1313

@@ -142,7 +142,9 @@ def parse_agent_config_json(raw: str) -> AgentConfigResolved:
142142

143143
def load_agent_config_from_state(state_file: str | Path) -> AgentConfigResolved:
144144
text = read_text(state_file)
145-
if text.startswith("---") and len(text.split("---", 2)) < 3:
145+
lines = text.splitlines()
146+
frontmatter, _body = split_frontmatter_document(text)
147+
if lines and lines[0].strip() == "---" and not frontmatter:
146148
raise ValueError("state frontmatter is unterminated")
147149
return parse_agent_config_frontmatter(extract_frontmatter(text))
148150

skills/bmad-story-automator/src/story_automator/core/diagnostics.py

Lines changed: 214 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import ast
34
import json
45
import os
56
import re
@@ -13,11 +14,35 @@
1314
MAX_COLLECTION_ITEMS = 6
1415
SECRET_KEY_PATTERN = r"(?:[A-Za-z0-9]+[_.-])*(?:authorization|credential|password|secret|token|api[_-]?key|access[_-]?key)(?:[_.-](?:hash|id|key|secret|value))?"
1516
SENSITIVE_KEY_RE = re.compile(rf"^{SECRET_KEY_PATTERN}$", re.IGNORECASE)
17+
SECRET_ASSIGNMENT_PREFIX_RE = re.compile(
18+
rf"(?i)(?<![A-Za-z0-9_.{{,-])(['\"]?)({SECRET_KEY_PATTERN})\1(?![A-Za-z0-9_.-])\s*[:=]\s*"
19+
)
1620
SECRET_QUOTED_ASSIGNMENT_RE = re.compile(
17-
rf"(?i)(?<![A-Za-z0-9_.-])({SECRET_KEY_PATTERN})(?![A-Za-z0-9_.-])\s*[:=]\s*(['\"])(?:(?!\2).)*\2"
21+
rf"(?i)(?<![A-Za-z0-9_.{{,-])(['\"]?)({SECRET_KEY_PATTERN})\1(?![A-Za-z0-9_.-])\s*[:=]\s*(['\"])(?!<redacted>\3)(?:(?!\3).)*\3"
1822
)
1923
SECRET_ASSIGNMENT_RE = re.compile(
20-
rf"(?i)(?<![A-Za-z0-9_.-])({SECRET_KEY_PATTERN})(?![A-Za-z0-9_.-])\s*[:=]\s*(?:(?:bearer|basic|token)\s+)?[^\s,;]+"
24+
rf"(?i)(?<![A-Za-z0-9_.{{,-])(['\"]?)({SECRET_KEY_PATTERN})\1(?![A-Za-z0-9_.-])\s*[:=]\s*(?!['\"]?<redacted>['\"]?)(?:(?:bearer|basic|token)\s+)?[^\s,;}}]+"
25+
)
26+
COMMA_SECRET_ASSIGNMENT_RE = re.compile(
27+
rf"(?i)(?<=,)({SECRET_KEY_PATTERN})(?![A-Za-z0-9_.-])\s*[:=]\s*(?!['\"]?<redacted>['\"]?)(?:(?:bearer|basic|token)\s+)?[^\s,;}}]+"
28+
)
29+
COMMA_SECRET_QUOTED_ASSIGNMENT_RE = re.compile(
30+
rf"(?i)(?<=,)({SECRET_KEY_PATTERN})(?![A-Za-z0-9_.-])\s*[:=]\s*(['\"])(?!<redacted>\2)(?:(?!\2).)*\2"
31+
)
32+
COMMA_SECRET_COLLECTION_ASSIGNMENT_RE = re.compile(
33+
rf"(?i)(?<=,)({SECRET_KEY_PATTERN})(?![A-Za-z0-9_.-])\s*[:=]\s*[\[{{].*$"
34+
)
35+
JSON_LIKE_SECRET_FIELD_RE = re.compile(
36+
rf"(?i)([{{,]\s*)(['\"])({SECRET_KEY_PATTERN})\2\s*:\s*(['\"])(?:(?!\4).)*\4"
37+
)
38+
JSON_LIKE_SECRET_UNQUOTED_FIELD_RE = re.compile(
39+
rf"(?i)([{{,]\s*)(['\"])({SECRET_KEY_PATTERN})\2\s*:\s*(?!['\"]?<redacted>['\"]?)(?:\[[^\]}}]*(?:\]|$)|\{{[^\]}}]*(?:\}}|$)|[^,}}\s]+)"
40+
)
41+
JSON_LIKE_SECRET_BARE_FIELD_RE = re.compile(
42+
rf"(?i)([{{,]\s*)({SECRET_KEY_PATTERN})(?![A-Za-z0-9_.-])\s*:\s*(?!<redacted>)(?:\[[^\]}}]*(?:\]|$)|\{{[^\]}}]*(?:\}}|$)|[^,}}\s]+)"
43+
)
44+
ESCAPED_JSON_SECRET_FIELD_RE = re.compile(
45+
rf"(?i)((?:\\)?['\"])({SECRET_KEY_PATTERN})\1\s*:\s*((?:\\)?['\"])(?:(?!\3).)*(?:\3|(?=,|$))"
2146
)
2247
SECRET_PATH_VALUE_ASSIGNMENT_RE = re.compile(
2348
rf"(?i)(?<![A-Za-z0-9_.-])({SECRET_KEY_PATTERN})(?![A-Za-z0-9_.-])\s*[:=]\s*(?:(?:bearer|basic|token)\s+)?<path:[^>]+>"
@@ -68,13 +93,13 @@ def serialize_issue(issue: DiagnosticIssue) -> dict[str, Any]:
6893
return {
6994
"type": issue.type,
7095
"field": issue.field,
71-
"expected": _json_safe(issue.expected),
96+
"expected": redact_actual(_json_safe(issue.expected)),
7297
"actual": redact_actual(issue.actual),
7398
"message": redact_actual(issue.message),
74-
"recovery": issue.recovery,
75-
"code": issue.code,
99+
"recovery": redact_actual(issue.recovery),
100+
"code": redact_actual(issue.code),
76101
"severity": issue.severity,
77-
"source": issue.source,
102+
"source": redact_actual(issue.source),
78103
}
79104

80105

@@ -173,18 +198,199 @@ def _json_safe(value: Any) -> Any:
173198

174199

175200
def _redact_string(value: str) -> str:
201+
structured = _redact_json_string(value)
202+
if structured is not None:
203+
return structured
204+
value = JSON_LIKE_SECRET_FIELD_RE.sub(lambda match: f"{match.group(1)}{match.group(2)}{match.group(3)}{match.group(2)}:{match.group(4)}<redacted>{match.group(4)}", value)
205+
value = _redact_sensitive_json_assignments(value)
206+
value = _redact_quoted_json_strings(value)
207+
value = _redact_embedded_json(value)
208+
value = JSON_LIKE_SECRET_UNQUOTED_FIELD_RE.sub(lambda match: f"{match.group(1)}{match.group(2)}{match.group(3)}{match.group(2)}:<redacted>", value)
209+
value = JSON_LIKE_SECRET_BARE_FIELD_RE.sub(lambda match: f"{match.group(1)}{match.group(2)}:<redacted>", value)
210+
value = ESCAPED_JSON_SECRET_FIELD_RE.sub(lambda match: f"{match.group(1)}{match.group(2)}{match.group(1)}:{match.group(3)}<redacted>{match.group(3)}", value)
176211
value = ABSOLUTE_PATH_WITH_EXT_RE.sub(_path_placeholder, value)
177212
value = ABSOLUTE_PATH_BEFORE_SECRET_RE.sub(_path_before_secret_placeholder, value)
178213
value = ABSOLUTE_PATH_RE.sub(_path_placeholder, value)
179214
value = SECRET_PATH_VALUE_ASSIGNMENT_RE.sub(lambda match: f"{match.group(1)}=<redacted>", value)
180215
value = SECRET_PATH_PLACEHOLDER_ASSIGNMENT_RE.sub(lambda match: f"{match.group(1)}=<redacted>", value)
181-
value = SECRET_QUOTED_ASSIGNMENT_RE.sub(lambda match: f"{match.group(1)}=<redacted>", value)
182-
value = SECRET_ASSIGNMENT_RE.sub(lambda match: f"{match.group(1)}=<redacted>", value)
216+
value = SECRET_QUOTED_ASSIGNMENT_RE.sub(lambda match: f"{match.group(2)}=<redacted>", value)
217+
value = SECRET_ASSIGNMENT_RE.sub(lambda match: f"{match.group(2)}=<redacted>", value)
218+
value = COMMA_SECRET_COLLECTION_ASSIGNMENT_RE.sub(lambda match: f"{match.group(1)}=<redacted>", value)
219+
value = COMMA_SECRET_QUOTED_ASSIGNMENT_RE.sub(lambda match: f"{match.group(1)}=<redacted>", value)
220+
value = COMMA_SECRET_ASSIGNMENT_RE.sub(lambda match: f"{match.group(1)}=<redacted>", value)
183221
if len(value) > MAX_STRING_LENGTH:
184222
return f"{value[:MAX_STRING_LENGTH]}...<truncated {len(value) - MAX_STRING_LENGTH} chars>"
185223
return value
186224

187225

226+
def _redact_json_string(value: str) -> str | None:
227+
stripped = value.strip()
228+
if not (stripped.startswith("{") or stripped.startswith("[")):
229+
return None
230+
try:
231+
parsed = json.loads(stripped)
232+
except json.JSONDecodeError:
233+
try:
234+
parsed = ast.literal_eval(stripped)
235+
except (SyntaxError, ValueError):
236+
return None
237+
if isinstance(parsed, str) and parsed.strip().startswith(("{", "[")):
238+
redacted = redact_actual(parsed)
239+
encoded = json.dumps(redacted, separators=(",", ":"))
240+
return encoded if len(encoded) <= MAX_STRING_LENGTH else f"{encoded[:MAX_STRING_LENGTH]}...<truncated {len(encoded) - MAX_STRING_LENGTH} chars>"
241+
redacted = redact_actual(parsed)
242+
encoded = json.dumps(redacted, separators=(",", ":"))
243+
return encoded if len(encoded) <= MAX_STRING_LENGTH else f"{encoded[:MAX_STRING_LENGTH]}...<truncated {len(encoded) - MAX_STRING_LENGTH} chars>"
244+
245+
246+
def _redact_sensitive_json_assignments(value: str) -> str:
247+
output: list[str] = []
248+
index = 0
249+
changed = False
250+
while index < len(value):
251+
match = SECRET_ASSIGNMENT_PREFIX_RE.match(value, index)
252+
if not match:
253+
output.append(value[index])
254+
index += 1
255+
continue
256+
value_start = match.end()
257+
if value_start >= len(value) or value[value_start] not in "{[":
258+
output.append(value[index])
259+
index += 1
260+
continue
261+
decoded = _decode_collection_prefix(value[value_start:])
262+
if decoded is None:
263+
output.append(f"{match.group(2)}=<redacted>")
264+
index = len(value)
265+
changed = True
266+
continue
267+
_parsed, end = decoded
268+
output.append(f"{match.group(2)}=<redacted>")
269+
index = value_start + end
270+
changed = True
271+
return "".join(output) if changed else value
272+
273+
274+
def _redact_quoted_json_strings(value: str) -> str:
275+
decoder = json.JSONDecoder()
276+
output: list[str] = []
277+
index = 0
278+
changed = False
279+
while index < len(value):
280+
if value[index] not in "\"'":
281+
output.append(value[index])
282+
index += 1
283+
continue
284+
try:
285+
parsed, end = decoder.raw_decode(value[index:])
286+
except json.JSONDecodeError:
287+
literal = _decode_quoted_literal_prefix(value[index:])
288+
if literal is None:
289+
output.append(value[index])
290+
index += 1
291+
continue
292+
parsed, end = literal
293+
if not (isinstance(parsed, str) and parsed.strip().startswith(("{", "["))):
294+
output.append(value[index])
295+
index += 1
296+
continue
297+
output.append(json.dumps(redact_actual(parsed), separators=(",", ":")))
298+
index += end
299+
changed = True
300+
return "".join(output) if changed else value
301+
302+
303+
def _redact_embedded_json(value: str) -> str:
304+
output: list[str] = []
305+
index = 0
306+
changed = False
307+
while index < len(value):
308+
if value[index] not in "{[":
309+
output.append(value[index])
310+
index += 1
311+
continue
312+
decoded = _decode_collection_prefix(value[index:])
313+
if decoded is None:
314+
output.append(value[index])
315+
index += 1
316+
continue
317+
parsed, end = decoded
318+
redacted = redact_actual(parsed)
319+
output.append(json.dumps(redacted, separators=(",", ":")))
320+
index += end
321+
changed = True
322+
return "".join(output) if changed else value
323+
324+
325+
def _decode_collection_prefix(value: str) -> tuple[Any, int] | None:
326+
decoder = json.JSONDecoder()
327+
try:
328+
return decoder.raw_decode(value)
329+
except json.JSONDecodeError:
330+
pass
331+
end = _balanced_collection_end(value)
332+
if end <= 0:
333+
return None
334+
try:
335+
return ast.literal_eval(value[:end]), end
336+
except (SyntaxError, ValueError):
337+
return None
338+
339+
340+
def _decode_quoted_literal_prefix(value: str) -> tuple[Any, int] | None:
341+
end = _quoted_literal_end(value)
342+
if end <= 0:
343+
return None
344+
try:
345+
return ast.literal_eval(value[:end]), end
346+
except (SyntaxError, ValueError):
347+
return None
348+
349+
350+
def _balanced_collection_end(value: str) -> int:
351+
if not value or value[0] not in "{[":
352+
return -1
353+
opening = {"{": "}", "[": "]"}
354+
stack = [opening[value[0]]]
355+
quote = ""
356+
escaped = False
357+
for index, char in enumerate(value[1:], start=1):
358+
if quote:
359+
if escaped:
360+
escaped = False
361+
elif char == "\\":
362+
escaped = True
363+
elif char == quote:
364+
quote = ""
365+
continue
366+
if char in {"'", '"'}:
367+
quote = char
368+
continue
369+
if char in opening:
370+
stack.append(opening[char])
371+
continue
372+
if stack and char == stack[-1]:
373+
stack.pop()
374+
if not stack:
375+
return index + 1
376+
return -1
377+
378+
379+
def _quoted_literal_end(value: str) -> int:
380+
if not value or value[0] not in {"'", '"'}:
381+
return -1
382+
quote = value[0]
383+
escaped = False
384+
for index, char in enumerate(value[1:], start=1):
385+
if escaped:
386+
escaped = False
387+
elif char == "\\":
388+
escaped = True
389+
elif char == quote:
390+
return index + 1
391+
return -1
392+
393+
188394
def _path_placeholder(match: re.Match[str]) -> str:
189395
path = match.group(0)
190396
name = path.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1]

skills/bmad-story-automator/src/story_automator/core/frontmatter.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,37 @@
99

1010

1111
def extract_frontmatter(text: str) -> str:
12-
if not text.startswith("---"):
12+
frontmatter, _body = split_frontmatter_document(text)
13+
if not frontmatter:
1314
return ""
14-
parts = text.split("---", 2)
15-
if len(parts) < 3:
16-
return ""
17-
return parts[1].lstrip("\n")
15+
return frontmatter_content(frontmatter).lstrip("\n")
1816

1917

2018
def split_frontmatter(text: str) -> tuple[str, str]:
21-
if not text.startswith("---"):
19+
frontmatter, body = split_frontmatter_document(text)
20+
if not frontmatter:
2221
return "", text
23-
parts = text.split("---", 2)
24-
if len(parts) < 3:
22+
return frontmatter_content(frontmatter).lstrip("\n"), body.lstrip("\n")
23+
24+
25+
def split_frontmatter_document(text: str) -> tuple[str, str]:
26+
lines = text.splitlines(keepends=True)
27+
if not lines or lines[0].strip() != "---":
2528
return "", text
26-
return parts[1].lstrip("\n"), parts[2].lstrip("\n")
29+
for index, line in enumerate(lines[1:], start=1):
30+
if line.strip() == "---":
31+
return "".join(lines[: index + 1]), "".join(lines[index + 1 :])
32+
return "", text
33+
34+
35+
def frontmatter_content(frontmatter: str) -> str:
36+
lines = frontmatter.splitlines(keepends=True)
37+
if not lines or lines[0].strip() != "---":
38+
return frontmatter
39+
for index, line in enumerate(lines[1:], start=1):
40+
if line.strip() == "---":
41+
return "".join(lines[1:index])
42+
return frontmatter
2743

2844

2945
def parse_simple_frontmatter(text: str) -> dict[str, Any]:

skills/bmad-story-automator/src/story_automator/core/monitoring.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,18 @@ def _normalize_structured_issue(structured_issue: dict[str, Any] | None) -> dict
5555
if structured_issue is None:
5656
return None
5757
if isinstance(structured_issue, dict) and isinstance(structured_issue.get("type"), str) and isinstance(structured_issue.get("field"), str):
58-
return structured_issue
58+
issue = DiagnosticIssue(
59+
type=str(structured_issue.get("type") or ""),
60+
field=str(structured_issue.get("field") or ""),
61+
expected=structured_issue.get("expected", ""),
62+
actual=structured_issue.get("actual", ""),
63+
message=str(structured_issue.get("message") or ""),
64+
recovery=str(structured_issue.get("recovery") or ""),
65+
code=str(structured_issue.get("code") or ""),
66+
severity=str(structured_issue.get("severity") or "error"),
67+
source=str(structured_issue.get("source") or "monitor-session"),
68+
)
69+
return serialize_issues([issue])[0]
5970
issue = DiagnosticIssue(
6071
type="invalid_type",
6172
field="structured_issue",

0 commit comments

Comments
 (0)