specfuse-loop 0.2.0__py3-none-any.whl
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.
- specfuse/loop/__init__.py +5 -0
- specfuse/loop/_miniyaml.py +466 -0
- specfuse/loop/adopt_feature.py +217 -0
- specfuse/loop/gate_eval.py +503 -0
- specfuse/loop/gh_backend.py +82 -0
- specfuse/loop/gh_features.py +98 -0
- specfuse/loop/lint_plan.py +616 -0
- specfuse/loop/loop.py +3504 -0
- specfuse/loop/validate_event.py +282 -0
- specfuse_loop-0.2.0.dist-info/METADATA +192 -0
- specfuse_loop-0.2.0.dist-info/RECORD +16 -0
- specfuse_loop-0.2.0.dist-info/WHEEL +5 -0
- specfuse_loop-0.2.0.dist-info/entry_points.txt +3 -0
- specfuse_loop-0.2.0.dist-info/licenses/LICENSE +201 -0
- specfuse_loop-0.2.0.dist-info/licenses/NOTICE +6 -0
- specfuse_loop-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Specfuse contributors
|
|
3
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
+
#
|
|
5
|
+
"""Strict mini-YAML parser for the loop's configuration subset.
|
|
6
|
+
|
|
7
|
+
The loop reads four kinds of YAML, all small and regular:
|
|
8
|
+
|
|
9
|
+
* Feature / GATE / WU **frontmatter** — flat block mappings of scalar values
|
|
10
|
+
(the only nesting in real files is `work_units` lists in PLAN.md, which is
|
|
11
|
+
not in frontmatter).
|
|
12
|
+
* The PLAN.md fenced ```yaml graph block — a nested block mapping with a
|
|
13
|
+
`gates:` list of objects, each containing a `work_units:` list of objects.
|
|
14
|
+
* **`verification.yml`** — top-level block mapping (`code`/`doc`/`plannext`)
|
|
15
|
+
each mapping to a list of `{name, command}` objects, where `command`
|
|
16
|
+
values are typically double-quoted strings.
|
|
17
|
+
* The agent's **RESULT block** — a forgiving consumer-side parse; on any
|
|
18
|
+
malformed input the caller catches the exception and falls back to
|
|
19
|
+
verify() as the exit oracle.
|
|
20
|
+
|
|
21
|
+
This parser implements EXACTLY that subset and FAILS LOUDLY on anything else.
|
|
22
|
+
PyYAML's safe_load is lenient and friendly; this one is the opposite by design:
|
|
23
|
+
the loop's structural files (PLAN, GATE, WU, verification.yml) are authored by
|
|
24
|
+
the operator, so a malformed file becoming a clear error is correct, not a
|
|
25
|
+
regression. The fail-loud principle matches `verify()`'s fail-closed posture
|
|
26
|
+
on a missing gate set — silent misparses are worse than crashes.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Grammar (the documented subset)
|
|
31
|
+
|
|
32
|
+
* **Block mapping** (`key: value`), nested by **2-space** indentation steps.
|
|
33
|
+
Keys must match `[A-Za-z_][A-Za-z0-9_-]*`. Values are on the same line
|
|
34
|
+
(scalar) or on subsequent more-indented lines (mapping/sequence).
|
|
35
|
+
* **Block sequence** (`- item`). Items can be scalars or mappings. A
|
|
36
|
+
mapping-item's first key sits inline after `- `; subsequent keys must
|
|
37
|
+
align to the column where the first key began (`indent + 2`). A block
|
|
38
|
+
sequence may sit at the **same** indent as its parent mapping key
|
|
39
|
+
(YAML 1.2 §8.2.1 — the `kubectl`/Helm style) or at a deeper indent;
|
|
40
|
+
both forms parse identically.
|
|
41
|
+
* **Scalars:**
|
|
42
|
+
- **bare strings** — everything from `: ` to end-of-line (with trailing
|
|
43
|
+
whitespace stripped, and a trailing ` # ...` comment removed). May
|
|
44
|
+
contain `,`, `/`, `-`, `.`, `=`, `::`, `{` / `}` / spaces, etc. The
|
|
45
|
+
only forbidden bare-start characters are the ones that would invoke
|
|
46
|
+
an unsupported feature (`'`, `&`, `*`, `!`, `|`, `>`, `{`).
|
|
47
|
+
- **double-quoted strings** — `"..."`, supporting only `\\` and `\"`
|
|
48
|
+
escapes. Any other escape is an error.
|
|
49
|
+
- **integers** — `0` or a positive decimal (no sign, no leading zeros).
|
|
50
|
+
- **floats** — positive decimal with required fractional part
|
|
51
|
+
(`0.5`, `1.25`, `10.0125`). No sign, no leading dot, no trailing
|
|
52
|
+
dot, no scientific notation. Used for things like `cost_usd`
|
|
53
|
+
fields that the driver writes to WU frontmatter.
|
|
54
|
+
- **booleans** — exact lowercase `true` / `false` only. `True`, `yes`,
|
|
55
|
+
`on`, etc. are errors.
|
|
56
|
+
- **empty value** — a key followed by `:` with nothing after is `None`.
|
|
57
|
+
* **Inline (flow) lists** — `[]`, `[a]`, `[a, b]`. Items are scalars only;
|
|
58
|
+
nested brackets are an error. Real usage is correlation-ID lists.
|
|
59
|
+
* **Comments** — `#` starting a line (after optional whitespace), or
|
|
60
|
+
`<space>#` on a value line, ends the significant content. `#` inside a
|
|
61
|
+
double-quoted string is NOT a comment.
|
|
62
|
+
* **Blank lines** — ignored.
|
|
63
|
+
|
|
64
|
+
## Explicitly UNSUPPORTED (fail-loud with line number)
|
|
65
|
+
|
|
66
|
+
* Anchors and aliases (`&foo`, `*foo`).
|
|
67
|
+
* Tags (`!!str`, `!Custom`).
|
|
68
|
+
* Block scalars (`|`, `>`).
|
|
69
|
+
* Single-quoted strings (`'...'`).
|
|
70
|
+
* Flow mappings (`{key: value}`).
|
|
71
|
+
* Multi-doc separators (`---` inside the body).
|
|
72
|
+
* Tab characters in indentation.
|
|
73
|
+
* Mixed / inconsistent indentation.
|
|
74
|
+
* Nested brackets inside a flow list.
|
|
75
|
+
* Escape sequences other than `\\` and `\"` in double-quoted strings.
|
|
76
|
+
* Boolean spellings other than exact lowercase `true` / `false`.
|
|
77
|
+
* Explicit null markers (`null`, `Null`, `NULL`, `~`).
|
|
78
|
+
* Integers with sign, leading zeros, floats, hex, or underscores.
|
|
79
|
+
|
|
80
|
+
A file using any of those raises `MiniYAMLError` with a line number and a
|
|
81
|
+
specific message telling the operator how to simplify.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
from __future__ import annotations
|
|
85
|
+
|
|
86
|
+
import re
|
|
87
|
+
from typing import Any
|
|
88
|
+
|
|
89
|
+
__all__ = ("parse", "MiniYAMLError")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class MiniYAMLError(Exception):
|
|
93
|
+
"""Raised when input uses YAML features outside the documented subset."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# A key is one or more name-chars, then a `:` (and optional value after a space).
|
|
97
|
+
# The key body cannot contain `:` or whitespace; the optional value is the rest.
|
|
98
|
+
_KEY_VALUE_RE = re.compile(r"^([^:\s][^:]*?)\s*:(?:\s+(.*))?$")
|
|
99
|
+
_VALID_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$")
|
|
100
|
+
_INT_RE = re.compile(r"^(0|[1-9]\d*)$")
|
|
101
|
+
# Positive decimal with required fractional part. Rejects: signed, leading
|
|
102
|
+
# dot, trailing dot, leading-zero non-zero integer part, scientific notation.
|
|
103
|
+
# Matches: 0.5, 1.5, 10.0125, 0.0
|
|
104
|
+
_FLOAT_RE = re.compile(r"^(0|[1-9]\d*)\.\d+$")
|
|
105
|
+
|
|
106
|
+
_FORBIDDEN_BOOLS = {
|
|
107
|
+
"True", "False",
|
|
108
|
+
"yes", "Yes", "YES", "no", "No", "NO",
|
|
109
|
+
"on", "On", "ON", "off", "Off", "OFF",
|
|
110
|
+
}
|
|
111
|
+
_FORBIDDEN_NULLS = {"null", "Null", "NULL", "~"}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# --------------------------------------------------------------------------- #
|
|
115
|
+
# Public entry point #
|
|
116
|
+
# --------------------------------------------------------------------------- #
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse(text: str) -> Any:
|
|
120
|
+
"""Parse the documented YAML subset; return a dict / list / scalar / None.
|
|
121
|
+
|
|
122
|
+
Raises ``MiniYAMLError`` on any construct outside the subset, with a line
|
|
123
|
+
number and a description of what was rejected.
|
|
124
|
+
"""
|
|
125
|
+
if text is None or text == "":
|
|
126
|
+
return None
|
|
127
|
+
lines = _tokenize(text)
|
|
128
|
+
if not lines:
|
|
129
|
+
return None
|
|
130
|
+
if lines[0].indent != 0:
|
|
131
|
+
raise MiniYAMLError(
|
|
132
|
+
f"line {lines[0].lineno}: top-level content must start at indent 0")
|
|
133
|
+
value, pos = _parse_block(lines, 0, 0)
|
|
134
|
+
if pos != len(lines):
|
|
135
|
+
raise MiniYAMLError(
|
|
136
|
+
f"line {lines[pos].lineno}: unexpected continuation past end of "
|
|
137
|
+
f"root structure (got {lines[pos].content!r})")
|
|
138
|
+
return value
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --------------------------------------------------------------------------- #
|
|
142
|
+
# Tokenization #
|
|
143
|
+
# --------------------------------------------------------------------------- #
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class _Line:
|
|
147
|
+
__slots__ = ("indent", "content", "lineno")
|
|
148
|
+
|
|
149
|
+
def __init__(self, indent: int, content: str, lineno: int):
|
|
150
|
+
self.indent = indent
|
|
151
|
+
self.content = content
|
|
152
|
+
self.lineno = lineno
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _tokenize(text: str) -> list[_Line]:
|
|
156
|
+
"""Split into significant lines, stripping comments and blanks."""
|
|
157
|
+
lines: list[_Line] = []
|
|
158
|
+
for n, raw in enumerate(text.splitlines(), start=1):
|
|
159
|
+
leading = raw[:len(raw) - len(raw.lstrip(" \t"))]
|
|
160
|
+
if "\t" in leading:
|
|
161
|
+
raise MiniYAMLError(
|
|
162
|
+
f"line {n}: tab in indentation unsupported — use spaces")
|
|
163
|
+
if raw.lstrip(" ").startswith("---") and n > 1:
|
|
164
|
+
# Multi-doc separator inside the body is unsupported. (A leading
|
|
165
|
+
# `---` only ever reaches the parser via direct callers that have
|
|
166
|
+
# already stripped frontmatter delimiters, so it should not appear.)
|
|
167
|
+
raise MiniYAMLError(
|
|
168
|
+
f"line {n}: multi-document separator `---` unsupported")
|
|
169
|
+
stripped = _strip_trailing_comment(raw, n)
|
|
170
|
+
if stripped.strip() == "":
|
|
171
|
+
continue
|
|
172
|
+
indent = len(stripped) - len(stripped.lstrip(" "))
|
|
173
|
+
content = stripped[indent:].rstrip()
|
|
174
|
+
lines.append(_Line(indent, content, n))
|
|
175
|
+
return lines
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _strip_trailing_comment(line: str, lineno: int) -> str:
|
|
179
|
+
"""Remove a trailing ``# ...`` comment.
|
|
180
|
+
|
|
181
|
+
A ``#`` only starts a comment when it sits at the start of the line OR is
|
|
182
|
+
preceded by whitespace (matching PyYAML behaviour). ``#`` inside a
|
|
183
|
+
double-quoted string is preserved.
|
|
184
|
+
"""
|
|
185
|
+
in_string = False
|
|
186
|
+
escape = False
|
|
187
|
+
for i, c in enumerate(line):
|
|
188
|
+
if escape:
|
|
189
|
+
escape = False
|
|
190
|
+
continue
|
|
191
|
+
if in_string:
|
|
192
|
+
if c == "\\":
|
|
193
|
+
escape = True
|
|
194
|
+
elif c == '"':
|
|
195
|
+
in_string = False
|
|
196
|
+
continue
|
|
197
|
+
if c == '"':
|
|
198
|
+
in_string = True
|
|
199
|
+
continue
|
|
200
|
+
if c == "#" and (i == 0 or line[i - 1] in " \t"):
|
|
201
|
+
return line[:i].rstrip()
|
|
202
|
+
return line
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# --------------------------------------------------------------------------- #
|
|
206
|
+
# Block / sequence / mapping #
|
|
207
|
+
# --------------------------------------------------------------------------- #
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _parse_block(lines: list[_Line], pos: int, indent: int) -> tuple[Any, int]:
|
|
211
|
+
"""Dispatch to mapping or sequence at the given indent."""
|
|
212
|
+
line = lines[pos]
|
|
213
|
+
if line.indent != indent:
|
|
214
|
+
raise MiniYAMLError(
|
|
215
|
+
f"line {line.lineno}: expected indent {indent}, got {line.indent}")
|
|
216
|
+
if line.content == "-" or line.content.startswith("- "):
|
|
217
|
+
return _parse_sequence(lines, pos, indent)
|
|
218
|
+
return _parse_mapping(lines, pos, indent)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _parse_mapping(lines: list[_Line], pos: int, indent: int) -> tuple[dict, int]:
|
|
222
|
+
result: dict = {}
|
|
223
|
+
while pos < len(lines) and lines[pos].indent == indent:
|
|
224
|
+
line = lines[pos]
|
|
225
|
+
if line.content.startswith("-"):
|
|
226
|
+
raise MiniYAMLError(
|
|
227
|
+
f"line {line.lineno}: sequence item where mapping key expected")
|
|
228
|
+
key, rhs = _split_key_value(line.content, line.lineno)
|
|
229
|
+
if key in result:
|
|
230
|
+
raise MiniYAMLError(
|
|
231
|
+
f"line {line.lineno}: duplicate key {key!r} in mapping")
|
|
232
|
+
pos += 1
|
|
233
|
+
result[key], pos = _resolve_value(lines, pos, rhs, indent, line.lineno)
|
|
234
|
+
return result, pos
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _parse_sequence(lines: list[_Line], pos: int, indent: int) -> tuple[list, int]:
|
|
238
|
+
result: list = []
|
|
239
|
+
while (pos < len(lines)
|
|
240
|
+
and lines[pos].indent == indent
|
|
241
|
+
and (lines[pos].content == "-" or lines[pos].content.startswith("- "))):
|
|
242
|
+
line = lines[pos]
|
|
243
|
+
if line.content == "-":
|
|
244
|
+
pos += 1
|
|
245
|
+
if pos >= len(lines) or lines[pos].indent <= indent:
|
|
246
|
+
raise MiniYAMLError(
|
|
247
|
+
f"line {line.lineno}: empty `-` item without a value")
|
|
248
|
+
value, pos = _parse_block(lines, pos, lines[pos].indent)
|
|
249
|
+
result.append(value)
|
|
250
|
+
continue
|
|
251
|
+
rest = line.content[2:]
|
|
252
|
+
m = _KEY_VALUE_RE.match(rest)
|
|
253
|
+
if m and _VALID_KEY_RE.match(m.group(1).strip()):
|
|
254
|
+
item, pos = _parse_inline_mapping_item(lines, pos, indent, rest)
|
|
255
|
+
result.append(item)
|
|
256
|
+
else:
|
|
257
|
+
result.append(_parse_scalar(rest, line.lineno))
|
|
258
|
+
pos += 1
|
|
259
|
+
return result, pos
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _parse_inline_mapping_item(
|
|
263
|
+
lines: list[_Line], pos: int, indent: int, first_rest: str,
|
|
264
|
+
) -> tuple[dict, int]:
|
|
265
|
+
"""Parse a `- key: value [<newline> more_keys]` mapping item."""
|
|
266
|
+
line = lines[pos]
|
|
267
|
+
sub_indent = indent + 2
|
|
268
|
+
item: dict = {}
|
|
269
|
+
key, rhs = _split_key_value(first_rest, line.lineno)
|
|
270
|
+
pos += 1
|
|
271
|
+
item[key], pos = _resolve_value(lines, pos, rhs, sub_indent, line.lineno)
|
|
272
|
+
# Subsequent keys for this same item are at sub_indent, not starting with `-`.
|
|
273
|
+
while (pos < len(lines)
|
|
274
|
+
and lines[pos].indent == sub_indent
|
|
275
|
+
and not lines[pos].content.startswith("-")):
|
|
276
|
+
kline = lines[pos]
|
|
277
|
+
k, v = _split_key_value(kline.content, kline.lineno)
|
|
278
|
+
if k in item:
|
|
279
|
+
raise MiniYAMLError(
|
|
280
|
+
f"line {kline.lineno}: duplicate key {k!r} in mapping item")
|
|
281
|
+
pos += 1
|
|
282
|
+
item[k], pos = _resolve_value(lines, pos, v, sub_indent, kline.lineno)
|
|
283
|
+
return item, pos
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _resolve_value(
|
|
287
|
+
lines: list[_Line], pos: int, rhs: str | None,
|
|
288
|
+
enclosing_indent: int, lineno: int,
|
|
289
|
+
) -> tuple[Any, int]:
|
|
290
|
+
"""Given the rhs of a `key:` line, return (value, new_pos).
|
|
291
|
+
|
|
292
|
+
If rhs is empty, look for the value on subsequent lines:
|
|
293
|
+
* a block sequence at the **same** indent as the parent key (YAML 1.2
|
|
294
|
+
§8.2.1 — `kubectl`-style and many human-edited configs use this); OR
|
|
295
|
+
* any block (sequence or sub-mapping) at a **deeper** indent.
|
|
296
|
+
If neither, the value is None.
|
|
297
|
+
|
|
298
|
+
Same-indent extension applies only to sequences. A sub-mapping at the
|
|
299
|
+
same indent is genuinely ambiguous with a sibling mapping key, so that
|
|
300
|
+
case stays an error.
|
|
301
|
+
"""
|
|
302
|
+
if rhs is None or rhs == "":
|
|
303
|
+
if pos < len(lines):
|
|
304
|
+
nxt = lines[pos]
|
|
305
|
+
if (nxt.indent == enclosing_indent
|
|
306
|
+
and (nxt.content == "-" or nxt.content.startswith("- "))):
|
|
307
|
+
value, pos = _parse_sequence(lines, pos, enclosing_indent)
|
|
308
|
+
return value, pos
|
|
309
|
+
if nxt.indent > enclosing_indent:
|
|
310
|
+
child_indent = nxt.indent
|
|
311
|
+
value, pos = _parse_block(lines, pos, child_indent)
|
|
312
|
+
return value, pos
|
|
313
|
+
return None, pos
|
|
314
|
+
return _parse_scalar(rhs, lineno), pos
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _split_key_value(content: str, lineno: int) -> tuple[str, str | None]:
|
|
318
|
+
"""Split a `key: value` (or `key:`) line. Returns (key, rhs_or_None)."""
|
|
319
|
+
m = _KEY_VALUE_RE.match(content)
|
|
320
|
+
if not m:
|
|
321
|
+
raise MiniYAMLError(
|
|
322
|
+
f"line {lineno}: not a `key: value` line — got {content!r}")
|
|
323
|
+
key = m.group(1).strip()
|
|
324
|
+
if not _VALID_KEY_RE.match(key):
|
|
325
|
+
raise MiniYAMLError(
|
|
326
|
+
f"line {lineno}: unsupported key {key!r} — keys must match "
|
|
327
|
+
f"[A-Za-z_][A-Za-z0-9_-]*")
|
|
328
|
+
rhs = m.group(2)
|
|
329
|
+
if rhs is not None:
|
|
330
|
+
rhs = rhs.strip()
|
|
331
|
+
return key, rhs
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# --------------------------------------------------------------------------- #
|
|
335
|
+
# Scalars #
|
|
336
|
+
# --------------------------------------------------------------------------- #
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _parse_scalar(text: str, lineno: int) -> Any:
|
|
340
|
+
"""Parse a scalar value — string, int, bool, empty/null, or flow list."""
|
|
341
|
+
text = text.strip() if text else ""
|
|
342
|
+
if text == "":
|
|
343
|
+
return None
|
|
344
|
+
if text.startswith("'"):
|
|
345
|
+
raise MiniYAMLError(
|
|
346
|
+
f"line {lineno}: single-quoted strings unsupported — use \"...\"")
|
|
347
|
+
if text.startswith("&") or text.startswith("*"):
|
|
348
|
+
raise MiniYAMLError(f"line {lineno}: anchors/aliases unsupported")
|
|
349
|
+
if text.startswith("!"):
|
|
350
|
+
raise MiniYAMLError(f"line {lineno}: tags unsupported")
|
|
351
|
+
if text.startswith("|") or text.startswith(">"):
|
|
352
|
+
raise MiniYAMLError(
|
|
353
|
+
f"line {lineno}: literal/folded block scalars unsupported")
|
|
354
|
+
if text.startswith("{"):
|
|
355
|
+
# Positional rule (LOAD-BEARING — do not tighten without thought):
|
|
356
|
+
# `{` is a fail-loud trigger ONLY when it starts the scalar — i.e. the
|
|
357
|
+
# value begins with `{`, which would be the start of a flow mapping.
|
|
358
|
+
# Braces appearing INSIDE a bare scalar are kept as ordinary content,
|
|
359
|
+
# because real agent result summaries routinely contain prose with
|
|
360
|
+
# braces (e.g. `summary: added GET /health returning {status, version}`),
|
|
361
|
+
# and matching PyYAML on that case is pinned by the equivalence tests.
|
|
362
|
+
# Tightening this to "any `{` anywhere rejects" would crash the
|
|
363
|
+
# forgiving result-block parse path on real agent output.
|
|
364
|
+
raise MiniYAMLError(
|
|
365
|
+
f"line {lineno}: flow mappings unsupported — use a block mapping")
|
|
366
|
+
if text in _FORBIDDEN_NULLS:
|
|
367
|
+
raise MiniYAMLError(
|
|
368
|
+
f"line {lineno}: explicit null marker unsupported — leave value empty")
|
|
369
|
+
if text in _FORBIDDEN_BOOLS:
|
|
370
|
+
raise MiniYAMLError(
|
|
371
|
+
f"line {lineno}: only lowercase `true`/`false` accepted as "
|
|
372
|
+
f"booleans (got {text!r})")
|
|
373
|
+
if text[0] == "[":
|
|
374
|
+
return _parse_flow_list(text, lineno)
|
|
375
|
+
if text[0] == '"':
|
|
376
|
+
return _decode_double_quoted(text, lineno)
|
|
377
|
+
if text == "true":
|
|
378
|
+
return True
|
|
379
|
+
if text == "false":
|
|
380
|
+
return False
|
|
381
|
+
if _INT_RE.match(text):
|
|
382
|
+
return int(text)
|
|
383
|
+
if _FLOAT_RE.match(text):
|
|
384
|
+
return float(text)
|
|
385
|
+
return text
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _parse_flow_list(text: str, lineno: int) -> list:
|
|
389
|
+
if not text.endswith("]"):
|
|
390
|
+
raise MiniYAMLError(
|
|
391
|
+
f"line {lineno}: malformed flow list (no closing `]`)")
|
|
392
|
+
inner = text[1:-1].strip()
|
|
393
|
+
if inner == "":
|
|
394
|
+
return []
|
|
395
|
+
return [_parse_scalar(item, lineno) for item in _split_flow_items(inner, lineno)]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _split_flow_items(inner: str, lineno: int) -> list[str]:
|
|
399
|
+
"""Split `a, b, c` on commas, respecting double-quoted strings; fail on
|
|
400
|
+
nested brackets."""
|
|
401
|
+
items: list[str] = []
|
|
402
|
+
buf: list[str] = []
|
|
403
|
+
in_string = False
|
|
404
|
+
escape = False
|
|
405
|
+
for c in inner:
|
|
406
|
+
if escape:
|
|
407
|
+
buf.append(c)
|
|
408
|
+
escape = False
|
|
409
|
+
continue
|
|
410
|
+
if in_string:
|
|
411
|
+
if c == "\\":
|
|
412
|
+
escape = True
|
|
413
|
+
elif c == '"':
|
|
414
|
+
in_string = False
|
|
415
|
+
buf.append(c)
|
|
416
|
+
continue
|
|
417
|
+
if c == '"':
|
|
418
|
+
in_string = True
|
|
419
|
+
buf.append(c)
|
|
420
|
+
continue
|
|
421
|
+
if c in "[]{}":
|
|
422
|
+
raise MiniYAMLError(
|
|
423
|
+
f"line {lineno}: nested brackets/braces in flow list unsupported")
|
|
424
|
+
if c == ",":
|
|
425
|
+
items.append("".join(buf).strip())
|
|
426
|
+
buf = []
|
|
427
|
+
continue
|
|
428
|
+
buf.append(c)
|
|
429
|
+
if in_string:
|
|
430
|
+
raise MiniYAMLError(
|
|
431
|
+
f"line {lineno}: unterminated double-quoted string inside flow list")
|
|
432
|
+
items.append("".join(buf).strip())
|
|
433
|
+
if any(item == "" for item in items):
|
|
434
|
+
raise MiniYAMLError(f"line {lineno}: empty item in flow list")
|
|
435
|
+
return items
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _decode_double_quoted(text: str, lineno: int) -> str:
|
|
439
|
+
if len(text) < 2 or not text.endswith('"'):
|
|
440
|
+
raise MiniYAMLError(f"line {lineno}: unterminated double-quoted string")
|
|
441
|
+
body = text[1:-1]
|
|
442
|
+
out: list[str] = []
|
|
443
|
+
i = 0
|
|
444
|
+
while i < len(body):
|
|
445
|
+
c = body[i]
|
|
446
|
+
if c == "\\":
|
|
447
|
+
if i + 1 >= len(body):
|
|
448
|
+
raise MiniYAMLError(
|
|
449
|
+
f"line {lineno}: dangling backslash in double-quoted string")
|
|
450
|
+
n = body[i + 1]
|
|
451
|
+
if n == "\\":
|
|
452
|
+
out.append("\\")
|
|
453
|
+
elif n == '"':
|
|
454
|
+
out.append('"')
|
|
455
|
+
else:
|
|
456
|
+
raise MiniYAMLError(
|
|
457
|
+
f"line {lineno}: unsupported escape \\{n} in "
|
|
458
|
+
f"double-quoted string (only \\\\ and \\\" supported)")
|
|
459
|
+
i += 2
|
|
460
|
+
elif c == '"':
|
|
461
|
+
raise MiniYAMLError(
|
|
462
|
+
f"line {lineno}: unescaped quote inside double-quoted string")
|
|
463
|
+
else:
|
|
464
|
+
out.append(c)
|
|
465
|
+
i += 1
|
|
466
|
+
return "".join(out)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Specfuse contributors
|
|
4
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
5
|
+
#
|
|
6
|
+
"""Adopt a picked specfuse:feature issue into a dispatchable loop-feature folder."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import gh_features as _gh_features
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _encode_id(feature_id: str) -> str:
|
|
18
|
+
"""Map INIT-YYYY-NNNN/FNN -> INIT-YYYY-NNNN-FNN; FEAT-YYYY-NNNN unchanged."""
|
|
19
|
+
return feature_id.replace("/", "-")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _make_slug(title: str) -> str:
|
|
23
|
+
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _plan_md(candidate: dict, encoded_id: str, slug: str) -> str:
|
|
27
|
+
fid = candidate["feature_id"]
|
|
28
|
+
title = candidate["title"]
|
|
29
|
+
branch = f"feat/{encoded_id}-{slug}"
|
|
30
|
+
autonomy = candidate.get("autonomy") or "review"
|
|
31
|
+
source_url = candidate.get("url", "")
|
|
32
|
+
initiative = candidate.get("initiative")
|
|
33
|
+
|
|
34
|
+
fm_lines = [
|
|
35
|
+
"---",
|
|
36
|
+
f"feature_id: {fid}",
|
|
37
|
+
f"title: {title}",
|
|
38
|
+
f"slug: {slug}",
|
|
39
|
+
f"branch: {branch}",
|
|
40
|
+
f"roadmap_goal: {title}",
|
|
41
|
+
f"autonomy_default: {autonomy}",
|
|
42
|
+
"status: planned",
|
|
43
|
+
f"source_issue_url: {source_url}",
|
|
44
|
+
]
|
|
45
|
+
if initiative is not None:
|
|
46
|
+
fm_lines.append(f"initiative: {initiative}")
|
|
47
|
+
fm_lines.append("---")
|
|
48
|
+
|
|
49
|
+
wu01_file = f"WU-01-{slug}.md"
|
|
50
|
+
graph = f"""\
|
|
51
|
+
```yaml
|
|
52
|
+
gates:
|
|
53
|
+
- gate: 1
|
|
54
|
+
file: GATE-01.md
|
|
55
|
+
work_units:
|
|
56
|
+
- id: {fid}/T01
|
|
57
|
+
file: {wu01_file}
|
|
58
|
+
depends_on: []
|
|
59
|
+
- id: {fid}/G1-RETRO
|
|
60
|
+
file: WU-90-gate-1-retrospective.md
|
|
61
|
+
depends_on: [{fid}/T01]
|
|
62
|
+
- id: {fid}/G1-LESSONS
|
|
63
|
+
file: WU-91-gate-1-lessons.md
|
|
64
|
+
depends_on: [{fid}/G1-RETRO]
|
|
65
|
+
- id: {fid}/G1-DOCS
|
|
66
|
+
file: WU-92-gate-1-docs.md
|
|
67
|
+
depends_on: [{fid}/G1-LESSONS]
|
|
68
|
+
- id: {fid}/G1-PLAN
|
|
69
|
+
file: WU-93-gate-1-plan-next.md
|
|
70
|
+
depends_on: [{fid}/G1-DOCS]
|
|
71
|
+
- gate: 2
|
|
72
|
+
file: GATE-02.md
|
|
73
|
+
work_units: []
|
|
74
|
+
- gate: 3
|
|
75
|
+
file: GATE-03.md
|
|
76
|
+
work_units: []
|
|
77
|
+
```"""
|
|
78
|
+
|
|
79
|
+
body = (
|
|
80
|
+
f"\n# Plan: {title}\n\n"
|
|
81
|
+
f"Adopted from GitHub issue: {source_url}\n\n"
|
|
82
|
+
"This file owns the **shape** of the feature: gate order, work units, "
|
|
83
|
+
"dependency edges.\n"
|
|
84
|
+
"WU files own their own status; GATE files own gate status.\n\n"
|
|
85
|
+
"## Task graph\n\n"
|
|
86
|
+
f"{graph}\n"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return "\n".join(fm_lines) + "\n" + body
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _wu01_md(candidate: dict) -> str:
|
|
93
|
+
fid = candidate["feature_id"]
|
|
94
|
+
title = candidate["title"]
|
|
95
|
+
task_type = candidate.get("task_type") or "implementation"
|
|
96
|
+
body_text = candidate.get("body", "")
|
|
97
|
+
|
|
98
|
+
fm = (
|
|
99
|
+
"---\n"
|
|
100
|
+
f"id: {fid}/T01\n"
|
|
101
|
+
f"type: {task_type}\n"
|
|
102
|
+
"model: claude-sonnet-4-6\n"
|
|
103
|
+
"status: draft\n"
|
|
104
|
+
"attempts: 0\n"
|
|
105
|
+
"---"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return f"{fm}\n\n# {title}\n\n**Objective.** TODO\n\n{body_text}\n"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _closing_wu(
|
|
112
|
+
feature_id: str, wu_id: str, wu_type: str, model: str, title: str
|
|
113
|
+
) -> str:
|
|
114
|
+
fm = (
|
|
115
|
+
"---\n"
|
|
116
|
+
f"id: {feature_id}/{wu_id}\n"
|
|
117
|
+
f"type: {wu_type}\n"
|
|
118
|
+
f"model: {model}\n"
|
|
119
|
+
"status: draft\n"
|
|
120
|
+
"attempts: 0\n"
|
|
121
|
+
"---"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
body = (
|
|
125
|
+
f"\n# {title}\n\n"
|
|
126
|
+
f"**Context.** This is the `{wu_id}` unit for feature `{feature_id}`.\n"
|
|
127
|
+
"Read the feature's `events.jsonl` and the commits on the feature branch.\n\n"
|
|
128
|
+
"**Acceptance criteria.** The artifact for this unit exists and is substantive.\n\n"
|
|
129
|
+
"**Do not touch.** Source code not owned by this unit, generated directories, "
|
|
130
|
+
"secrets, `.git/`. The driver owns all git — edit files only.\n\n"
|
|
131
|
+
"**Verification.** The `doc` gates in `.specfuse/verification.yml` "
|
|
132
|
+
"(the artifact exists and something changed).\n\n"
|
|
133
|
+
"**Escalation triggers.** If the event log is too sparse to complete this unit "
|
|
134
|
+
"honestly, say so in the artifact rather than inventing content.\n"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return fm + body
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _gate_md(gate_num: int, title_line: str, body_text: str) -> str:
|
|
141
|
+
return (
|
|
142
|
+
f"---\ngate: {gate_num}\nstatus: open\n---\n\n"
|
|
143
|
+
f"# Gate {gate_num} — {title_line}\n\n"
|
|
144
|
+
"## Definition of done\n\n"
|
|
145
|
+
f"{body_text}\n\n"
|
|
146
|
+
"## Reflection notes\n\n"
|
|
147
|
+
"<written by the human at review time>\n"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def adopt_feature(candidate: dict, root: Path) -> Path:
|
|
152
|
+
"""Scaffold a loop-feature folder from a picked specfuse:feature candidate.
|
|
153
|
+
|
|
154
|
+
Returns the created folder path.
|
|
155
|
+
Raises FileExistsError if the folder already exists.
|
|
156
|
+
"""
|
|
157
|
+
fid = candidate["feature_id"]
|
|
158
|
+
encoded_id = _encode_id(fid)
|
|
159
|
+
slug = _make_slug(candidate["title"])
|
|
160
|
+
folder = root / f"{encoded_id}-{slug}"
|
|
161
|
+
|
|
162
|
+
if folder.exists():
|
|
163
|
+
raise FileExistsError(f"folder already exists: {folder}")
|
|
164
|
+
|
|
165
|
+
folder.mkdir(parents=True)
|
|
166
|
+
|
|
167
|
+
(folder / "PLAN.md").write_text(_plan_md(candidate, encoded_id, slug))
|
|
168
|
+
(folder / "GATE-01.md").write_text(
|
|
169
|
+
_gate_md(1, candidate["title"], f"Gate 1 implementation complete: {candidate['title']}")
|
|
170
|
+
)
|
|
171
|
+
(folder / "GATE-02.md").write_text(
|
|
172
|
+
_gate_md(2, "Next gate", "Drafted by gate 1's plan-next.")
|
|
173
|
+
)
|
|
174
|
+
(folder / f"WU-01-{slug}.md").write_text(_wu01_md(candidate))
|
|
175
|
+
(folder / "WU-90-gate-1-retrospective.md").write_text(
|
|
176
|
+
_closing_wu(fid, "G1-RETRO", "retrospective", "claude-sonnet-4-6", "Gate 1 retrospective")
|
|
177
|
+
)
|
|
178
|
+
(folder / "WU-91-gate-1-lessons.md").write_text(
|
|
179
|
+
_closing_wu(fid, "G1-LESSONS", "lessons", "claude-sonnet-4-6", "Gate 1 lessons")
|
|
180
|
+
)
|
|
181
|
+
(folder / "WU-92-gate-1-docs.md").write_text(
|
|
182
|
+
_closing_wu(fid, "G1-DOCS", "docs", "claude-sonnet-4-6", "Gate 1 documentation update")
|
|
183
|
+
)
|
|
184
|
+
(folder / "WU-93-gate-1-plan-next.md").write_text(
|
|
185
|
+
_closing_wu(fid, "G1-PLAN", "plan-next", "claude-opus-4-7", "Gate 1 plan next gate")
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return folder
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main(_runner=None, _root=None) -> None:
|
|
192
|
+
"""CLI: adopt_feature.py <repo> <issue-number>"""
|
|
193
|
+
import argparse
|
|
194
|
+
|
|
195
|
+
parser = argparse.ArgumentParser(
|
|
196
|
+
description="Adopt a specfuse:feature GitHub issue into a loop-feature folder."
|
|
197
|
+
)
|
|
198
|
+
parser.add_argument("repo", help="GitHub repo (owner/repo)")
|
|
199
|
+
parser.add_argument("issue_number", type=int, help="Issue number to adopt")
|
|
200
|
+
parsed = parser.parse_args()
|
|
201
|
+
|
|
202
|
+
root = _root if _root is not None else Path(".specfuse/features")
|
|
203
|
+
candidates = _gh_features.list_features(parsed.repo, runner=_runner)
|
|
204
|
+
matched = [c for c in candidates if c["number"] == parsed.issue_number]
|
|
205
|
+
if not matched:
|
|
206
|
+
print(
|
|
207
|
+
f"no specfuse:feature issue with number {parsed.issue_number} in {parsed.repo}",
|
|
208
|
+
file=sys.stderr,
|
|
209
|
+
)
|
|
210
|
+
sys.exit(1)
|
|
211
|
+
|
|
212
|
+
folder = adopt_feature(matched[0], root=root)
|
|
213
|
+
print(folder)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
main()
|