feature-flow-cli 0.1.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.
Files changed (44) hide show
  1. feature_flow/__init__.py +3 -0
  2. feature_flow/__main__.py +7 -0
  3. feature_flow/_bundle/adapters/codex/feature-flow/SKILL.md +62 -0
  4. feature_flow/_bundle/adapters/codex/feature-flow/agents/openai.yaml +6 -0
  5. feature_flow/_bundle/agents/ticket-builder.md +24 -0
  6. feature_flow/_bundle/agents/ticket-reviewer.md +16 -0
  7. feature_flow/_bundle/guides/build.md +139 -0
  8. feature_flow/_bundle/guides/plan.md +93 -0
  9. feature_flow/_bundle/guides/review.md +87 -0
  10. feature_flow/_bundle/guides/show.md +30 -0
  11. feature_flow/_bundle/guides/templates/commands.md +15 -0
  12. feature_flow/_bundle/guides/templates/learnings.md +7 -0
  13. feature_flow/_bundle/guides/templates/map.md +26 -0
  14. feature_flow/_bundle/guides/templates/spec.md +62 -0
  15. feature_flow/_bundle/guides/templates/ticket.md +29 -0
  16. feature_flow/_bundle/guides/templates/ui-mockup.md +43 -0
  17. feature_flow/_bundle/scripts/floor-guard.py +14 -0
  18. feature_flow/_bundle/scripts/flow-status.py +14 -0
  19. feature_flow/_bundle/scripts/flow-view.html +536 -0
  20. feature_flow/_bundle/scripts/flow-view.py +14 -0
  21. feature_flow/_bundle/scripts/flow.py +14 -0
  22. feature_flow/_bundle/scripts/gate.py +14 -0
  23. feature_flow/_bundle/skills/architect-review/SKILL.md +143 -0
  24. feature_flow/_bundle/skills/automation-design/SKILL.md +274 -0
  25. feature_flow/_bundle/skills/feature-flow/SKILL.md +62 -0
  26. feature_flow/checks.py +71 -0
  27. feature_flow/cli.py +47 -0
  28. feature_flow/command.py +39 -0
  29. feature_flow/conductor.py +299 -0
  30. feature_flow/floorguard.py +340 -0
  31. feature_flow/gate.py +100 -0
  32. feature_flow/git.py +47 -0
  33. feature_flow/install.py +365 -0
  34. feature_flow/prompts.py +52 -0
  35. feature_flow/state.py +75 -0
  36. feature_flow/status.py +231 -0
  37. feature_flow/tickets.py +168 -0
  38. feature_flow/view.py +312 -0
  39. feature_flow_cli-0.1.0.dist-info/METADATA +293 -0
  40. feature_flow_cli-0.1.0.dist-info/RECORD +44 -0
  41. feature_flow_cli-0.1.0.dist-info/WHEEL +5 -0
  42. feature_flow_cli-0.1.0.dist-info/entry_points.txt +3 -0
  43. feature_flow_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  44. feature_flow_cli-0.1.0.dist-info/top_level.txt +1 -0
feature_flow/gate.py ADDED
@@ -0,0 +1,100 @@
1
+ """The gate: a port of the bash gate in scripts/.
2
+
3
+ usage: python3 scripts/gate.py <feature>
4
+ runs the Build, Test and Lint commands from plans/<feature>/commands.md in that order and
5
+ stops at the first one that fails. empty values and placeholders in angle brackets are skipped.
6
+ the commands run from the current directory and must exit non zero on failure.
7
+ exit 0 everything passed or nothing to run, 1 a command failed, 2 usage error.
8
+
9
+ The file is read and the output written as bytes, so the text matches the bash version byte for
10
+ byte (awk keeps a trailing carriage return, and tail prints a command's output unchanged).
11
+ """
12
+
13
+ import os
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ from pathlib import Path
19
+
20
+ KEYS = ("Build", "Test", "Lint")
21
+ TAIL = 40
22
+
23
+
24
+ def shell(cmd):
25
+ """How to run a command from commands.md: bash -c on Linux and macOS, the system shell (cmd.exe)
26
+ on Windows, where `bash` on PATH may be the Windows Subsystem for Linux stub and git-bash is not
27
+ a requirement. Returns (args, use_shell) for subprocess."""
28
+ if sys.platform == "win32":
29
+ return cmd, True
30
+ return ["bash", "-c", cmd], False
31
+
32
+
33
+ def command(path, key):
34
+ """The first `<key>:` line of commands.md, as the awk line in the bash gate reads it, or b""."""
35
+ key = key.encode()
36
+ try:
37
+ data = Path(path).read_bytes()
38
+ except OSError: # awk prints its own error to stderr and the value is empty
39
+ return b""
40
+ for line in data.split(b"\n"): # a Windows checkout may end lines with \r; it is dropped below
41
+ if line.startswith(key + b":"):
42
+ value = re.sub(b"^" + re.escape(key) + b":[ \t]*", b"", line, count=1)
43
+ return value.rstrip(b"\r").replace(b"`", b"")
44
+ return b""
45
+
46
+
47
+ def tail(data, n=TAIL):
48
+ """The last n lines of data, as tail -n prints them (a last line without a newline counts)."""
49
+ if not data:
50
+ return b""
51
+ if data.endswith(b"\n"):
52
+ return b"\n".join(data[:-1].split(b"\n")[-n:]) + b"\n"
53
+ return b"\n".join(data.split(b"\n")[-n:])
54
+
55
+
56
+ def run(feature, out, err):
57
+ """Run the gate for feature. out and err take bytes. Returns the exit code."""
58
+ if not feature:
59
+ # the bash gate's usage line, word for word (parity); ticket 07 switches it to gate.py
60
+ err(b"usage: python3 scripts/gate.py <feature>\n")
61
+ return 2
62
+ name = os.fsencode(feature)
63
+ path = Path(os.environ.get("FLOW_DIR") or "plans") / feature / "commands.md"
64
+ if not path.is_file():
65
+ out(b"gate: no commands.md for " + name + b", nothing to run\n")
66
+ return 0
67
+ ran = 0
68
+ for key in KEYS:
69
+ cmd = command(path, key)
70
+ if cmd == b"" or cmd.startswith(b"<"):
71
+ continue
72
+ ran += 1
73
+ out(b"gate: " + key.encode() + b": " + cmd + b"\n")
74
+ with tempfile.TemporaryFile() as log:
75
+ args, use_shell = shell(os.fsdecode(cmd))
76
+ code = subprocess.call(args, shell=use_shell, stdout=log, stderr=subprocess.STDOUT)
77
+ if code != 0:
78
+ log.seek(0)
79
+ out(b"gate: " + key.encode() + b" failed. the last lines of its output:\n")
80
+ out(tail(log.read()))
81
+ return 1
82
+ if ran > 0:
83
+ out(b"gate: %d command(s) passed\n" % ran)
84
+ else:
85
+ out(b"gate: no commands defined, nothing to run\n")
86
+ return 0
87
+
88
+
89
+ def _writer(stream):
90
+ def write(data):
91
+ stream.buffer.write(data)
92
+ stream.buffer.flush()
93
+ return write
94
+
95
+
96
+ def main(argv=None):
97
+ argv = sys.argv[1:] if argv is None else argv
98
+ sys.stdout.flush()
99
+ sys.stderr.flush()
100
+ return run(argv[0] if argv else "", _writer(sys.stdout), _writer(sys.stderr))
feature_flow/git.py ADDED
@@ -0,0 +1,47 @@
1
+ """The few git calls the conductor needs, each one an argument list, never a shell string."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+
7
+ def _git(*args, check=True):
8
+ result = subprocess.run(["git"] + list(args), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
9
+ universal_newlines=True)
10
+ if check and result.returncode != 0:
11
+ raise RuntimeError("git %s failed: %s" % (" ".join(args), result.stderr.strip()))
12
+ return result
13
+
14
+
15
+ def git_dir():
16
+ result = _git("rev-parse", "--absolute-git-dir", check=False)
17
+ if result.returncode != 0:
18
+ return None
19
+ return Path(result.stdout.strip())
20
+
21
+
22
+ def toplevel():
23
+ result = _git("rev-parse", "--show-toplevel", check=False)
24
+ if result.returncode != 0 or not result.stdout.strip():
25
+ return None
26
+ return Path(result.stdout.strip())
27
+
28
+
29
+ def head():
30
+ return _git("rev-parse", "HEAD").stdout.strip()
31
+
32
+
33
+ def is_clean():
34
+ """No change at all, untracked files included, as `git status --porcelain` sees it."""
35
+ return _git("status", "--porcelain").stdout.strip() == ""
36
+
37
+
38
+ def tracked_clean():
39
+ """No change to a tracked file, in the worktree or the index."""
40
+ return (_git("diff", "--quiet", check=False).returncode == 0
41
+ and _git("diff", "--cached", "--quiet", check=False).returncode == 0)
42
+
43
+
44
+ def commit_file(path, message):
45
+ if _git("add", "--", str(path), check=False).returncode != 0:
46
+ return False
47
+ return _git("commit", "-q", "-m", message, check=False).returncode == 0
@@ -0,0 +1,365 @@
1
+ """The installer, ported from install.sh with the same report and exit codes.
2
+
3
+ usage: python3 install.py [target-repo] [--agent claude|codex|all] [--user] [--force] [--dry-run]
4
+ copies the feature-flow skill, its roles, scripts and guides into a repo so the flow works there.
5
+ a file that already exists and differs is kept and reported, unless --force is given.
6
+ nothing is ever deleted. CLAUDE_HOME overrides ~/.claude and AGENTS_HOME overrides ~/.agents for --user.
7
+
8
+ Files are compared and written as bytes, and the two text transforms (the Codex skill header and
9
+ a role file without its frontmatter) follow the awk programs of the bash version line for line,
10
+ so the installed tree is the same byte for byte. Files are listed in byte order, as `sort` does
11
+ with LC_ALL=C.
12
+ """
13
+
14
+ import os
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+
20
+ # the help text
21
+ USAGE = """\
22
+ usage: python3 install.py [target-repo] [--agent claude|codex|all] [--user] [--force] [--dry-run]
23
+ copies the feature-flow skill, its roles, scripts and guides into a repo so the flow works there.
24
+ --agent claude (default)
25
+ skills go to <target>/.claude/skills/ (or ~/.claude/skills/ with --user)
26
+ agents go to <target>/.claude/agents/ (or ~/.claude/agents/ with --user)
27
+ --agent codex: the Codex skill from adapters/codex/, the other skills with a Codex header
28
+ skills go to <target>/.agents/skills/ (or ~/.agents/skills/ with --user)
29
+ roles go to <target>/.agents/flow-roles/ (always: the skill reads them from the repo)
30
+ --agent all does both.
31
+ scripts go to <target>/scripts/ and guides, roles and the Python package to <target>/.feature-flow/
32
+ (always: the skill and scripts/flow.py read them from the repo)
33
+ a file that already exists and differs is kept and reported, unless --force is given.
34
+ nothing is ever deleted. CLAUDE_HOME overrides ~/.claude and AGENTS_HOME overrides ~/.agents for --user.
35
+ """
36
+
37
+ # inside an installed package the skills, roles, guides and scripts sit in this folder of feature_flow
38
+ BUNDLE = "_bundle"
39
+
40
+ # the skills this installs
41
+ SKILLS = ("feature-flow", "architect-review", "automation-design")
42
+
43
+ # an old skill that runs the ticket script (the bash one or the Python one) or ends a review with a verdict
44
+ LEFTOVER = re.compile(rb"scripts/flow-status\.(?:sh|py)|REVIEW: PASS")
45
+
46
+
47
+ class Usage(Exception):
48
+ """A usage error: the message, and whether the help text follows it."""
49
+
50
+ def __init__(self, message, help_too=False):
51
+ Exception.__init__(self, message)
52
+ self.help_too = help_too
53
+
54
+
55
+ def _lines(data):
56
+ """The records awk reads: split on newlines, a last line without one still counts."""
57
+ if not data:
58
+ return []
59
+ lines = data.split(b"\n")
60
+ if data.endswith(b"\n"):
61
+ lines.pop()
62
+ return lines
63
+
64
+
65
+ def codex_header(data):
66
+ """A Codex skill header holds only the name and a quoted description; the body is left as it is."""
67
+ out = []
68
+ fm = False
69
+ for nr, line in enumerate(_lines(data), 1):
70
+ if nr == 1 and line == b"---":
71
+ fm = True
72
+ out.append(line)
73
+ elif fm and line == b"---":
74
+ fm = False
75
+ out.append(line)
76
+ elif fm and line.startswith(b"name:"):
77
+ out.append(line)
78
+ elif fm and line.startswith(b"description:"):
79
+ d = re.sub(rb"[ \t]+$", b"", re.sub(rb"^[ \t]+", b"", line[12:], count=1), count=1)
80
+ e = d.replace(b"\\", b"\\\\").replace(b'"', b'\\"')
81
+ out.append(b'description: "' + e + b'"')
82
+ elif fm:
83
+ continue
84
+ else:
85
+ out.append(line)
86
+ return b"".join(line + b"\n" for line in out)
87
+
88
+
89
+ def role_body(data):
90
+ """A role file without its frontmatter (and the blank lines after it), to go in front of a prompt."""
91
+ out = []
92
+ fm = skip = False
93
+ for nr, line in enumerate(_lines(data), 1):
94
+ if nr == 1 and line == b"---":
95
+ fm = True
96
+ elif fm and line == b"---":
97
+ fm = False
98
+ skip = True
99
+ elif fm:
100
+ continue
101
+ elif skip and line == b"":
102
+ continue
103
+ else:
104
+ skip = False
105
+ out.append(line)
106
+ return b"".join(line + b"\n" for line in out)
107
+
108
+
109
+ def _files(src, skip_python=True):
110
+ """`find src -type f ! -name .DS_Store [! -name '*.pyc' ! -path '*/__pycache__/*'] | sort`."""
111
+ found = []
112
+ for top, _dirs, names in os.walk(src):
113
+ rel = os.path.relpath(top, src).replace(os.sep, "/")
114
+ prefix = src + "/" if rel == "." else src + "/" + rel + "/"
115
+ for name in names:
116
+ path = prefix + name
117
+ if os.path.islink(path) or not os.path.isfile(path) or name == ".DS_Store":
118
+ continue
119
+ if skip_python and (name.endswith(".pyc") or "/__pycache__/" in path):
120
+ continue
121
+ found.append(path)
122
+ return sorted(found, key=os.fsencode)
123
+
124
+
125
+ def source_root():
126
+ """Where the skills, roles, guides and scripts are: the copy inside an installed package, else the checkout."""
127
+ package = os.path.dirname(os.path.abspath(__file__))
128
+ bundle = os.path.join(package, BUNDLE)
129
+ return bundle if os.path.isdir(bundle) else os.path.dirname(package)
130
+
131
+
132
+ def _read(path):
133
+ try:
134
+ with open(path, "rb") as f:
135
+ return f.read()
136
+ except OSError:
137
+ return None
138
+
139
+
140
+ def _logical_cwd():
141
+ """The directory bash's pwd prints: $PWD when it names the current directory, else the real one."""
142
+ pwd = os.environ.get("PWD", "")
143
+ try:
144
+ if os.path.isabs(pwd) and os.path.samefile(pwd, "."):
145
+ return os.path.normpath(pwd)
146
+ except OSError:
147
+ pass
148
+ return os.getcwd()
149
+
150
+
151
+ class Installer:
152
+ def __init__(self, here, target, agent, user_level, force, dry, out):
153
+ self.here = here
154
+ self.target = target
155
+ self.agent = agent
156
+ self.force = force
157
+ self.dry = dry
158
+ self.out = out
159
+ if user_level:
160
+ home = os.environ.get("HOME", "")
161
+ self.claude_dir = os.environ.get("CLAUDE_HOME") or home + "/.claude"
162
+ self.agents_dir = os.environ.get("AGENTS_HOME") or home + "/.agents"
163
+ else:
164
+ self.claude_dir = target + "/.claude"
165
+ self.agents_dir = target + "/.agents"
166
+ # the Python package: the checkout's, else (inside an installed package) this one
167
+ package = here + "/feature_flow"
168
+ if not os.path.isfile(package + "/__init__.py"):
169
+ package = os.path.dirname(os.path.abspath(__file__))
170
+ self.package = package
171
+ self.added = self.updated = self.same = self.kept = 0
172
+
173
+ def place(self, file, dest, data=None):
174
+ """One file: add it, replace it (--force), leave it alone (same) or keep the user's version.
175
+ data is a transformed copy of file, written with mode 644."""
176
+ new = _read(file) if data is None else data
177
+ if not os.path.exists(dest):
178
+ self.out(" add " + dest)
179
+ self.added += 1
180
+ if not self.dry:
181
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
182
+ self._copy(file, dest, data)
183
+ elif new is not None and _read(dest) == new:
184
+ self.same += 1
185
+ elif self.force:
186
+ self.out(" update " + dest)
187
+ self.updated += 1
188
+ if not self.dry:
189
+ self._copy(file, dest, data)
190
+ else:
191
+ self.out(" kept " + dest + " (differs from this version, use --force to replace it)")
192
+ self.kept += 1
193
+
194
+ @staticmethod
195
+ def _copy(file, dest, data):
196
+ """cp -p: contents, mode and times."""
197
+ if data is None:
198
+ shutil.copy2(file, dest)
199
+ return
200
+ with open(dest, "wb") as f:
201
+ f.write(data)
202
+ os.chmod(dest, 0o644)
203
+
204
+ def copy_tree(self, src, dest, skip=None):
205
+ """Every file under src, except those under src/skip."""
206
+ if not os.path.isdir(src):
207
+ return
208
+ for file in _files(src):
209
+ rel = file[len(src) + 1:]
210
+ if skip and rel.startswith(skip + "/"):
211
+ continue
212
+ self.place(file, dest + "/" + rel)
213
+
214
+ def install_codex(self):
215
+ here = self.here
216
+ self.copy_tree(here + "/adapters/codex/feature-flow", self.agents_dir + "/skills/feature-flow")
217
+ for name in SKILLS:
218
+ if name == "feature-flow":
219
+ continue
220
+ skill = here + "/skills/" + name
221
+ for file in _files(skill, skip_python=False) if os.path.isdir(skill) else []:
222
+ if file == skill + "/SKILL.md":
223
+ data = _read(file)
224
+ self.place(file, self.agents_dir + "/skills/" + name + "/SKILL.md",
225
+ codex_header(data if data is not None else b""))
226
+ else:
227
+ self.place(file, self.agents_dir + "/skills/" + name + "/" + file[len(skill) + 1:])
228
+ agents = here + "/agents"
229
+ names = sorted(os.listdir(agents), key=os.fsencode) if os.path.isdir(agents) else []
230
+ for name in names:
231
+ file = agents + "/" + name
232
+ if name.startswith(".") or not name.endswith(".md") or not os.path.isfile(file):
233
+ continue
234
+ data = _read(file)
235
+ self.place(file, self.target + "/.agents/flow-roles/" + name,
236
+ role_body(data if data is not None else b""))
237
+
238
+ def report_leftovers(self, dirs):
239
+ """A skill folder this installer does not own but that drives the flow (it runs the ticket
240
+ scripts or ends a review with a verdict) is left from an earlier version: named once, never deleted."""
241
+ found = ""
242
+ for d in dirs:
243
+ names = sorted(os.listdir(d), key=os.fsencode) if os.path.isdir(d) else []
244
+ for name in names:
245
+ path = d + "/" + name + "/SKILL.md"
246
+ if name.startswith(".") or not os.path.isfile(path) or name in SKILLS:
247
+ continue
248
+ data = _read(path)
249
+ if data is not None and LEFTOVER.search(data):
250
+ found += " " + name
251
+ if found:
252
+ self.out("note: left from an earlier feature-flow, no longer installed:" + found
253
+ + ". delete them, /feature-flow replaces them")
254
+
255
+ def run(self):
256
+ here, agent = self.here, self.agent
257
+ if self.dry:
258
+ self.out("dry run: nothing will be written")
259
+ self.out("installing into " + self.target + ("" if agent == "claude" else " (%s)" % agent))
260
+ left = []
261
+ if agent in ("claude", "all"):
262
+ for name in SKILLS:
263
+ self.copy_tree(here + "/skills/" + name, self.claude_dir + "/skills/" + name)
264
+ self.copy_tree(here + "/agents", self.claude_dir + "/agents")
265
+ left.append(self.claude_dir + "/skills")
266
+ if agent in ("codex", "all"):
267
+ self.install_codex()
268
+ left.append(self.agents_dir + "/skills")
269
+ self.copy_tree(here + "/scripts", self.target + "/scripts")
270
+ self.copy_tree(here + "/guides", self.target + "/.feature-flow/guides")
271
+ self.copy_tree(here + "/agents", self.target + "/.feature-flow/agents")
272
+ self.copy_tree(self.package, self.target + "/.feature-flow/feature_flow", skip=BUNDLE)
273
+
274
+ self.out("")
275
+ verb = "would add" if self.dry else "added"
276
+ self.out("%s %d, updated %d, already current %d, kept %d"
277
+ % (verb, self.added, self.updated, self.same, self.kept))
278
+ self.report_leftovers(left)
279
+
280
+ if not self.dry:
281
+ try:
282
+ git = subprocess.run(["git", "-C", self.target, "rev-parse", "--git-dir"],
283
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode
284
+ except OSError:
285
+ git = 127
286
+ if git != 0:
287
+ self.out("note: %s is not a git repository, and the flow needs one" % self.target)
288
+ if not shutil.which("python3"):
289
+ self.out("note: install python3 (3.9 or later): scripts/flow.py needs it")
290
+ if agent in ("claude", "all"):
291
+ self.out("next: /feature-flow <feature-name> in Claude Code")
292
+ if agent in ("codex", "all"):
293
+ self.out("next: $feature-flow <feature-name> in Codex")
294
+ return 0
295
+
296
+
297
+ def parse(argv):
298
+ """The options, read in order like the bash case statement. Returns a dict, or None for --help."""
299
+ opts = {"target": "", "agent": "claude", "user": False, "force": False, "dry": False}
300
+ i = 0
301
+ while i < len(argv):
302
+ a = argv[i]
303
+ if a == "--agent":
304
+ if i + 1 >= len(argv):
305
+ raise Usage("--agent needs a value: claude, codex or all", help_too=True)
306
+ i += 1
307
+ opts["agent"] = argv[i]
308
+ elif a.startswith("--agent="):
309
+ opts["agent"] = a[len("--agent="):]
310
+ elif a == "--user":
311
+ opts["user"] = True
312
+ elif a == "--force":
313
+ opts["force"] = True
314
+ elif a == "--dry-run":
315
+ opts["dry"] = True
316
+ elif a in ("-h", "--help"):
317
+ return None
318
+ elif a.startswith("-"):
319
+ raise Usage("unknown option: " + a, help_too=True)
320
+ else:
321
+ opts["target"] = a
322
+ i += 1
323
+ if opts["agent"] not in ("claude", "codex", "all"):
324
+ raise Usage("unknown agent: %s (use claude, codex or all)" % opts["agent"])
325
+ return opts
326
+
327
+
328
+ def run(argv, here, out, err):
329
+ """Run the installer. out and err take one line of text each. Returns the exit code."""
330
+ try:
331
+ opts = parse(argv)
332
+ except Usage as e:
333
+ err(str(e))
334
+ if e.help_too:
335
+ err(USAGE[:-1])
336
+ return 2
337
+ if opts is None:
338
+ out(USAGE[:-1])
339
+ return 0
340
+ target = opts["target"] or "."
341
+ if not os.path.isdir(target):
342
+ err("no such directory: " + target)
343
+ return 2
344
+ target = os.path.normpath(os.path.join(_logical_cwd(), target))
345
+ installer = Installer(os.path.abspath(str(here)), target, opts["agent"], opts["user"],
346
+ opts["force"], opts["dry"], out)
347
+ return installer.run()
348
+
349
+
350
+ def main(here, argv=None):
351
+ """The command line: writes each line as bytes, flushed, so stdout and stderr keep their order."""
352
+ argv = sys.argv[1:] if argv is None else argv
353
+
354
+ def writer(stream):
355
+ def write(line):
356
+ stream.buffer.write(os.fsencode(line) + b"\n")
357
+ stream.buffer.flush()
358
+ return write
359
+
360
+ sys.stdout.flush()
361
+ try:
362
+ return run(argv, here, writer(sys.stdout), writer(sys.stderr))
363
+ except OSError as e: # cp or mkdir failed: the bash installer stopped there too (set -e)
364
+ writer(sys.stderr)("install.py: %s" % e)
365
+ return 1
@@ -0,0 +1,52 @@
1
+ """The prompts a session hands to its builder and reviewer, built from the role files and the guides.
2
+
3
+ The text is runtime neutral: any agent that can read a prompt can follow it.
4
+ """
5
+
6
+ from pathlib import Path
7
+
8
+ ROLES = {"build": "ticket-builder.md", "review": "ticket-reviewer.md"}
9
+ GUIDES = {"build": "build.md", "review": "review.md"}
10
+
11
+
12
+ def find_file(scripts, folder, name):
13
+ """folder/name beside the scripts folder (a checkout), else in .feature-flow/ (an install).
14
+
15
+ A candidate counts only when it holds the file, so an unrelated guides/ or agents/ folder
16
+ in the user's repo does not hide the installed one.
17
+ """
18
+ for root in (Path(scripts).parent, Path(".feature-flow")):
19
+ path = root / folder / name
20
+ if path.is_file():
21
+ return path
22
+ raise FileNotFoundError("cannot find %s/%s beside scripts/ or in .feature-flow/" % (folder, name))
23
+
24
+
25
+ def _body(path):
26
+ text = Path(path).read_text(encoding="utf-8")
27
+ if text.startswith("---\n"):
28
+ parts = text.split("\n---\n", 1)
29
+ if len(parts) == 2:
30
+ text = parts[1]
31
+ return text.strip("\n")
32
+
33
+
34
+ def build(phase, scripts, feature, ticket, num, sha):
35
+ role = _body(find_file(scripts, "agents", ROLES[phase]))
36
+ guide = _body(find_file(scripts, "guides", GUIDES[phase]))
37
+ for placeholder, value in (("<feature>", feature), ("<NN>", num), ("<ticket>", ticket), ("<sha>", sha),
38
+ ("<base>", sha), ("<base-commit>", sha), ("<start-commit>", sha)):
39
+ guide = guide.replace(placeholder, value)
40
+ fresh = ("Run `git log` and `git status` yourself before you start: a git snapshot taken when your "
41
+ "session began can be stale.")
42
+ if phase == "build":
43
+ task = ("Your task: build ticket %s of the feature `%s`, in auto mode (`%s auto %s`). The ticket is `%s`. "
44
+ "The work starts at commit `%s`.\n%s\nWhen you are done, reply with what you built and the "
45
+ "commit. Do not review your own work." % (num, feature, feature, num, ticket, sha, fresh))
46
+ intro = "The build guide follows. Follow it exactly."
47
+ else:
48
+ task = ("Your task: review ticket %s of the feature `%s` (`%s %s %s`). The ticket is `%s`, and the work "
49
+ "started at commit `%s`.\n%s\nDo not edit any file or create a commit. Your final reply must end "
50
+ "with exactly `REVIEW: PASS` or `REVIEW: FAIL`." % (num, feature, feature, num, sha, ticket, sha, fresh))
51
+ intro = "The review guide follows. Follow it exactly."
52
+ return "\n\n".join([role, "---", intro, guide, "---", task]) + "\n"
feature_flow/state.py ADDED
@@ -0,0 +1,75 @@
1
+ """The conductor's state and log, kept in .feature-flow/state/ at the top of the worktree.
2
+
3
+ Not under .git: a normal Codex session cannot write there, and an unattended Claude session is
4
+ refused there. The folder ignores itself (its .gitignore is `*`), so it never dirties the tree.
5
+ The state file is key=value lines. It is parsed as plain text, never run as code.
6
+ """
7
+
8
+ import shutil
9
+ import time
10
+ from pathlib import Path
11
+
12
+ STATE_DIR = Path(".feature-flow") / "state"
13
+ KINDS = ("state", "log", "findings")
14
+
15
+
16
+ def state_dir(top):
17
+ """The state folder under the worktree top, created with its ignore rule on first use."""
18
+ path = Path(top) / STATE_DIR
19
+ path.mkdir(parents=True, exist_ok=True)
20
+ ignore = path / ".gitignore"
21
+ if not ignore.is_file():
22
+ ignore.write_text("*\n", encoding="utf-8")
23
+ return path
24
+
25
+
26
+ def file_path(folder, feature, kind):
27
+ return Path(folder) / ("flow-%s.%s" % (feature, kind))
28
+
29
+
30
+ def state_path(folder, feature):
31
+ return file_path(folder, feature, "state")
32
+
33
+
34
+ def log_path(folder, feature):
35
+ return file_path(folder, feature, "log")
36
+
37
+
38
+ def migrate(old_folder, new_folder, feature):
39
+ """Carry a run an earlier version kept under .git into the new folder, owner included, so the
40
+ owning session keeps its token. Only when the new folder has no state for the feature yet.
41
+ The state file is copied last, so a half-done copy is retried. The old files are left alone."""
42
+ if old_folder is None or state_path(new_folder, feature).is_file():
43
+ return False
44
+ if not state_path(old_folder, feature).is_file():
45
+ return False
46
+ for kind in reversed(KINDS):
47
+ old = file_path(old_folder, feature, kind)
48
+ if old.is_file():
49
+ shutil.copyfile(str(old), str(file_path(new_folder, feature, kind)))
50
+ return True
51
+
52
+
53
+ def load(path):
54
+ data = {}
55
+ path = Path(path)
56
+ if not path.is_file():
57
+ return data
58
+ for line in path.read_text(encoding="utf-8").splitlines():
59
+ key, sep, value = line.partition("=")
60
+ if sep and key.strip():
61
+ data[key.strip()] = value
62
+ return data
63
+
64
+
65
+ def save(path, data):
66
+ path = Path(path)
67
+ lines = ["%s=%s" % (key, str(value).replace("\n", " ")) for key, value in data.items()]
68
+ tmp = path.with_name(path.name + ".tmp")
69
+ tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
70
+ tmp.replace(path)
71
+
72
+
73
+ def log(path, num, event):
74
+ with open(path, "a", encoding="utf-8") as handle:
75
+ handle.write("%s,%s,%s\n" % (time.strftime("%H:%M:%S"), num or "-", event))