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.
- feature_flow/__init__.py +3 -0
- feature_flow/__main__.py +7 -0
- feature_flow/_bundle/adapters/codex/feature-flow/SKILL.md +62 -0
- feature_flow/_bundle/adapters/codex/feature-flow/agents/openai.yaml +6 -0
- feature_flow/_bundle/agents/ticket-builder.md +24 -0
- feature_flow/_bundle/agents/ticket-reviewer.md +16 -0
- feature_flow/_bundle/guides/build.md +139 -0
- feature_flow/_bundle/guides/plan.md +93 -0
- feature_flow/_bundle/guides/review.md +87 -0
- feature_flow/_bundle/guides/show.md +30 -0
- feature_flow/_bundle/guides/templates/commands.md +15 -0
- feature_flow/_bundle/guides/templates/learnings.md +7 -0
- feature_flow/_bundle/guides/templates/map.md +26 -0
- feature_flow/_bundle/guides/templates/spec.md +62 -0
- feature_flow/_bundle/guides/templates/ticket.md +29 -0
- feature_flow/_bundle/guides/templates/ui-mockup.md +43 -0
- feature_flow/_bundle/scripts/floor-guard.py +14 -0
- feature_flow/_bundle/scripts/flow-status.py +14 -0
- feature_flow/_bundle/scripts/flow-view.html +536 -0
- feature_flow/_bundle/scripts/flow-view.py +14 -0
- feature_flow/_bundle/scripts/flow.py +14 -0
- feature_flow/_bundle/scripts/gate.py +14 -0
- feature_flow/_bundle/skills/architect-review/SKILL.md +143 -0
- feature_flow/_bundle/skills/automation-design/SKILL.md +274 -0
- feature_flow/_bundle/skills/feature-flow/SKILL.md +62 -0
- feature_flow/checks.py +71 -0
- feature_flow/cli.py +47 -0
- feature_flow/command.py +39 -0
- feature_flow/conductor.py +299 -0
- feature_flow/floorguard.py +340 -0
- feature_flow/gate.py +100 -0
- feature_flow/git.py +47 -0
- feature_flow/install.py +365 -0
- feature_flow/prompts.py +52 -0
- feature_flow/state.py +75 -0
- feature_flow/status.py +231 -0
- feature_flow/tickets.py +168 -0
- feature_flow/view.py +312 -0
- feature_flow_cli-0.1.0.dist-info/METADATA +293 -0
- feature_flow_cli-0.1.0.dist-info/RECORD +44 -0
- feature_flow_cli-0.1.0.dist-info/WHEEL +5 -0
- feature_flow_cli-0.1.0.dist-info/entry_points.txt +3 -0
- feature_flow_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
feature_flow/install.py
ADDED
|
@@ -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
|
feature_flow/prompts.py
ADDED
|
@@ -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))
|