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.
@@ -0,0 +1,5 @@
1
+ #
2
+ # Copyright 2026 Specfuse Contributors
3
+ # Licensed under the Apache License, Version 2.0. See LICENSE.
4
+ #
5
+ """specfuse.loop — the Specfuse loop driver package."""
@@ -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()