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/status.py ADDED
@@ -0,0 +1,231 @@
1
+ """The ticket graph reader, ported from the bash script of the same name.
2
+
3
+ usage: python3 scripts/flow-status.py <feature> [--next | --counts | --check | --mermaid [plain] | --json]
4
+ Exit codes for --next: 0 a ready ticket path is printed, 10 all done, 11 unfinished but nothing ready.
5
+ """
6
+
7
+ import os
8
+ import re
9
+ import sys
10
+
11
+ from feature_flow import tickets
12
+
13
+ USAGE = "usage: python3 scripts/flow-status.py <feature> [--next | --counts | --check | --mermaid [plain] | --json]"
14
+
15
+
16
+ def _env(name, default):
17
+ return os.environ.get(name) or default
18
+
19
+
20
+ def jstr(s):
21
+ out = ['"']
22
+ for c in s:
23
+ if c == "\\":
24
+ out.append("\\\\")
25
+ elif c == '"':
26
+ out.append('\\"')
27
+ elif c == "\t":
28
+ out.append("\\t")
29
+ elif c == "\001":
30
+ out.append("\\n")
31
+ elif c < " ":
32
+ pass
33
+ else:
34
+ out.append(c)
35
+ out.append('"')
36
+ return "".join(out)
37
+
38
+
39
+ def run(argv, out, err):
40
+ """Write what the bash script would to out and err, and return its exit code."""
41
+ feature = argv[0] if len(argv) > 0 else ""
42
+ mode = argv[1] if len(argv) > 1 and argv[1] != "" else "table"
43
+ opt = argv[2] if len(argv) > 2 else ""
44
+ if feature == "":
45
+ err.write(USAGE + "\n")
46
+ return 2
47
+ root = _env("FLOW_DIR", "plans")
48
+ folder = "%s/%s/%s" % (root, feature, _env("FLOW_TICKETS", "tasks"))
49
+ if not os.path.isdir(folder):
50
+ err.write("no ticket folder: %s\n" % folder)
51
+ return 2
52
+ paths = tickets.ticket_files(folder)
53
+ if not paths:
54
+ err.write("no tickets in %s\n" % folder)
55
+ return 2
56
+
57
+ if mode == "--check":
58
+ learnings = "%s/%s/learnings.md" % (root, feature)
59
+ if os.path.isfile(learnings):
60
+ lines = sum(1 for line in tickets.records(learnings) if line.strip(" \t\n\v\f\r") != "")
61
+ if lines > 40:
62
+ out.write("warning: learnings.md has %d lines. every session reads it, so a human should prune it\n"
63
+ % lines)
64
+
65
+ g = tickets.Graph(paths)
66
+ return _report(g, mode, opt, feature, out, err)
67
+
68
+
69
+ def _report(g, mode, opt, feature, out, err):
70
+ n = len(g.order)
71
+ count = {"resolved": 0, "open": 0, "claimed": 0, "waiting": 0, "parked": 0, "unknown": 0}
72
+ ready = {}
73
+ first = None
74
+ unfinished = 0
75
+ for t in g.order:
76
+ ready[t] = False
77
+ count[g.stat[t]] += 1
78
+ if g.stat[t] == "open":
79
+ if all(d in g.seen and g.stat[d] == "resolved" for d in g.blk[t]):
80
+ ready[t] = True
81
+ if first is None:
82
+ first = t
83
+ if g.stat[t] not in ("resolved", "parked"):
84
+ unfinished += 1
85
+ nready = sum(1 for t in g.order if ready[t])
86
+
87
+ if mode == "--next":
88
+ if first is not None:
89
+ out.write(g.path[first] + "\n")
90
+ return 0
91
+ if unfinished == 0:
92
+ err.write("COMPLETE\n")
93
+ return 10
94
+ err.write("stuck: %d claimed, %d waiting, %d open but blocked, %d unknown status\n"
95
+ % (count["claimed"], count["waiting"], count["open"], count["unknown"]))
96
+ return 11
97
+
98
+ counts = (n, count["resolved"], count["open"], count["claimed"], count["waiting"], count["parked"],
99
+ count["unknown"], nready)
100
+
101
+ if mode == "--counts":
102
+ out.write("total=%d resolved=%d open=%d claimed=%d waiting=%d parked=%d unknown=%d ready=%d\n" % counts)
103
+ return 0
104
+
105
+ if mode == "--json":
106
+ parts = []
107
+ for t in g.order:
108
+ bl = ",".join(jstr(g.lab[d] if d in g.seen else "%02d" % d) for d in g.blk[t])
109
+ parts.append('{"id":%d,"label":%s,"title":%s,"status":%s,"type":%s,"test_first":%s,"blocked_by":[%s],'
110
+ '"ready":%s,"file":%s}'
111
+ % (t, jstr(g.lab[t]), jstr(g.title[t]), jstr(g.stat[t]), jstr(g.typ.get(t) or "task"),
112
+ jstr(g.tf.get(t, "")), bl, "true" if ready[t] else "false", jstr(g.path[t])))
113
+ out.write('{"feature":%s,"tickets":[%s],"counts":{"total":%d,"resolved":%d,"open":%d,"claimed":%d,'
114
+ '"waiting":%d,"parked":%d,"unknown":%d,"ready":%d}}\n' % ((jstr(feature), ",".join(parts)) + counts))
115
+ return 0
116
+
117
+ if mode == "--mermaid":
118
+ out.write("flowchart TD\n")
119
+ for t in g.order:
120
+ cls = g.stat[t]
121
+ if cls == "open":
122
+ cls = "ready" if ready[t] else "open"
123
+ if cls == "unknown":
124
+ cls = "open"
125
+ suffix = "" if opt == "plain" else ":::" + cls
126
+ out.write(' T%s["%s %s"]%s\n' % (g.lab[t], g.lab[t], g.title[t].replace('"', "'"), suffix))
127
+ for t in g.order:
128
+ for d in g.blk[t]:
129
+ if d in g.seen:
130
+ out.write(" T%s --> T%s\n" % (g.lab[d], g.lab[t]))
131
+ if opt != "plain":
132
+ out.write(" classDef resolved fill:#d4edda,stroke:#2e7d32\n"
133
+ " classDef ready fill:#fff3cd,stroke:#b8860b\n"
134
+ " classDef open fill:#f5f5f5,stroke:#999999\n"
135
+ " classDef claimed fill:#cfe2ff,stroke:#1d4ed8\n"
136
+ " classDef waiting fill:#fde2e2,stroke:#b91c1c\n"
137
+ " classDef parked fill:#eeeeee,stroke:#bbbbbb,stroke-dasharray:4 3\n")
138
+ return 0
139
+
140
+ if mode == "--check":
141
+ return _check(g, ready, nready, unfinished, out)
142
+
143
+ for t in g.order:
144
+ bl = ",".join("%02d" % d for d in g.blk[t])
145
+ out.write("%s %-9s %-6s %-12s %s\n" % (g.lab[t], g.stat[t], "READY" if ready[t] else "-",
146
+ "after " + bl if bl else "", g.title[t]))
147
+ return 0
148
+
149
+
150
+ def _check(g, ready, nready, unfinished, out):
151
+ errors = []
152
+ for t in g.order:
153
+ lab = g.lab[t]
154
+ if t in g.dup:
155
+ errors.append(lab + ": two tickets share this number")
156
+ if not g.hasstatus[t]:
157
+ errors.append(lab + ": no Status line in the first 20 lines")
158
+ elif g.stat[t] == "unknown":
159
+ errors.append(lab + ": unrecognised Status value")
160
+ if not g.hasdone[t]:
161
+ errors.append(lab + ": no ## Done when section")
162
+ if t in g.hastf and g.tf[t] not in ("yes", "no"):
163
+ errors.append(lab + ": Test first must be yes or no")
164
+ for d in g.blk[t]:
165
+ if d == t:
166
+ errors.append(lab + ": blocked by itself")
167
+ elif d not in g.seen:
168
+ errors.append("%s: blocked by %d, which does not exist" % (lab, d))
169
+
170
+ placed = set()
171
+ progress = True
172
+ left = len(g.order)
173
+ while progress and left > 0:
174
+ progress = False
175
+ for t in g.order:
176
+ if t in placed:
177
+ continue
178
+ if all(not (d in g.seen and d != t and d not in placed) for d in g.blk[t]):
179
+ placed.add(t)
180
+ progress = True
181
+ left -= 1
182
+ if left > 0:
183
+ errors.extend(g.lab[t] + ": part of a dependency cycle" for t in g.order if t not in placed)
184
+ if nready == 0 and unfinished > 0:
185
+ errors.append("no ticket is ready to start")
186
+
187
+ reach = set()
188
+ for _ in range(len(g.order)):
189
+ changed = False
190
+ for t in g.order:
191
+ for d in g.blk[t]:
192
+ if d not in g.seen:
193
+ continue
194
+ if (t, d) not in reach:
195
+ reach.add((t, d))
196
+ changed = True
197
+ for x in g.order:
198
+ if (d, x) in reach and (t, x) not in reach:
199
+ reach.add((t, x))
200
+ changed = True
201
+ if not changed:
202
+ break
203
+ for t in g.order:
204
+ for m in g.mentions.get(t, []):
205
+ if m == t or m not in g.seen or (t, m) in reach or (m, t) in reach:
206
+ continue
207
+ out.write("warning: %s: mentions ticket %s but is not ordered against it. add it to Blocked by, or reword\n"
208
+ % (g.lab[t], g.lab[m]))
209
+ if errors:
210
+ for e in errors:
211
+ out.write(e + "\n")
212
+ return 1
213
+ out.write("OK: %d tickets, %d ready now\n" % (len(g.order), nready))
214
+ return 0
215
+
216
+
217
+ class _Bytes:
218
+ """Writes text to a byte stream, keeping the bytes awk would have passed through untouched."""
219
+
220
+ def __init__(self, stream):
221
+ self.stream = stream
222
+
223
+ def write(self, text):
224
+ self.stream.write(text.encode("utf-8", "surrogateescape"))
225
+ self.stream.flush()
226
+
227
+
228
+ def main(argv=None):
229
+ argv = list(sys.argv[1:] if argv is None else argv)
230
+ sys.stdout.flush()
231
+ return run(argv, _Bytes(sys.stdout.buffer), _Bytes(sys.stderr.buffer))
@@ -0,0 +1,168 @@
1
+ """Reading and changing ticket files."""
2
+
3
+ import os
4
+ import re
5
+ from pathlib import Path
6
+
7
+ DONE = ("resolved", "done", "closed")
8
+ RESET = ("claimed", "in-progress")
9
+
10
+
11
+ def name(path):
12
+ return Path(path).stem
13
+
14
+
15
+ def number(path):
16
+ return name(path).split("-", 1)[0]
17
+
18
+
19
+ def _lines(path):
20
+ return Path(path).read_text(encoding="utf-8").splitlines(True)
21
+
22
+
23
+ def status(path):
24
+ """The first word of the Status line in the first 20 lines, lower case."""
25
+ for line in _lines(path)[:20]:
26
+ if line.startswith("Status:"):
27
+ value = line[len("Status:"):].strip().lower()
28
+ return value.split()[0] if value else ""
29
+ return ""
30
+
31
+
32
+ def set_open(path):
33
+ lines = _lines(path)
34
+ for i, line in enumerate(lines[:20]):
35
+ if line.startswith("Status:"):
36
+ lines[i] = "Status: open\n"
37
+ break
38
+ Path(path).write_text("".join(lines), encoding="utf-8")
39
+
40
+
41
+ def append_findings(path, round_no, source, findings):
42
+ text = "\n## Review findings (round %d, %s)\n\n%s\n" % (round_no, source, findings.rstrip("\n"))
43
+ with open(path, "a", encoding="utf-8") as handle:
44
+ handle.write(text)
45
+
46
+
47
+ def commands_value(path, key):
48
+ """A command from commands.md, or "" when it is missing or a placeholder in angle brackets."""
49
+ path = Path(path)
50
+ if not path.is_file():
51
+ return ""
52
+ for line in path.read_text(encoding="utf-8").splitlines():
53
+ if re.match(r"^%s:" % re.escape(key), line):
54
+ value = re.sub(r"^%s:[ \t]*" % re.escape(key), "", line).replace("`", "")
55
+ return "" if value.startswith("<") else value
56
+ return ""
57
+
58
+
59
+ # The ticket graph, read the way the bash flow-status script's awk program reads it.
60
+
61
+ STATUS_WORDS = {
62
+ "resolved": "resolved", "done": "resolved", "closed": "resolved",
63
+ "claimed": "claimed", "in-progress": "claimed",
64
+ "parked": "parked", "wontfix": "parked",
65
+ "needs-triage": "waiting", "needs-info": "waiting", "ready-for-human": "waiting", "waiting": "waiting",
66
+ "open": "open", "ready-for-agent": "open",
67
+ }
68
+ _FILE_RE = re.compile(r"^[0-9][0-9].*-.*\.md$")
69
+ _MENTION_RE = re.compile(r"[Tt]ickets? +[0-9]+( *(,|and|&) *[0-9]+)*")
70
+ _DIGITS_RE = re.compile(r"[0-9]+")
71
+
72
+
73
+ def records(path):
74
+ """The file's lines: split on \\n only, no final empty record, bytes kept. A trailing \\r is dropped,
75
+ so a ticket saved with Windows line endings reads the same as one saved with \\n."""
76
+ text = Path(path).read_bytes().decode("utf-8", "surrogateescape")
77
+ if text == "":
78
+ return []
79
+ lines = text.split("\n")
80
+ if text.endswith("\n"):
81
+ lines.pop()
82
+ return [line[:-1] if line.endswith("\r") else line for line in lines]
83
+
84
+
85
+ def ticket_files(folder):
86
+ """What the glob [0-9][0-9]*-*.md finds in the folder, as folder/name, in name order."""
87
+ folder = str(folder)
88
+ try:
89
+ names = sorted(n for n in os.listdir(folder) if _FILE_RE.match(n))
90
+ except OSError:
91
+ return []
92
+ return ["%s/%s" % (folder, n) for n in names]
93
+
94
+
95
+ def norm_status(value):
96
+ s = value.lower()
97
+ s = re.sub(r"^[ \t]+", "", s)
98
+ s = re.sub(r"[ \t(].*$", "", s, flags=re.S)
99
+ return STATUS_WORDS.get(s, "unknown")
100
+
101
+
102
+ class Graph:
103
+ """Every ticket in a folder, keyed by number. The fields follow the awk arrays one to one."""
104
+
105
+ def __init__(self, paths):
106
+ self.seen = set()
107
+ self.dup = set()
108
+ self.path, self.lab, self.stat, self.title = {}, {}, {}, {}
109
+ self.hasstatus, self.hasdone, self.blk = {}, {}, {}
110
+ self.hasblk, self.hastype, self.hastf, self.hastitle = set(), set(), set(), set()
111
+ self.typ, self.tf = {}, {}
112
+ self.mentions = {}
113
+ for p in paths:
114
+ self._read(p)
115
+ self.order = sorted(self.seen)
116
+
117
+ def _read(self, filename):
118
+ lines = records(filename)
119
+ if not lines:
120
+ return # awk never starts an empty file
121
+ base = filename.rsplit("/", 1)[-1]
122
+ label = re.match(r"^[0-9]+", base).group(0)
123
+ cur = int(label)
124
+ if cur in self.path:
125
+ self.dup.add(cur)
126
+ self.path[cur] = filename
127
+ self.lab[cur] = label
128
+ self.stat[cur] = "unknown"
129
+ self.hasstatus[cur] = False
130
+ self.hasdone[cur] = False
131
+ self.blk[cur] = []
132
+ self.title[cur] = base
133
+ self.seen.add(cur)
134
+ skipm = False
135
+ ment = self.mentions.setdefault(cur, [])
136
+ for fnr, line in enumerate(lines, 1):
137
+ head = fnr <= 20
138
+ if head and line.startswith("Status:") and not self.hasstatus[cur]:
139
+ self.stat[cur] = norm_status(line[len("Status:"):])
140
+ self.hasstatus[cur] = True
141
+ if head and line.startswith("Blocked by:") and cur not in self.hasblk:
142
+ self.hasblk.add(cur)
143
+ self.blk[cur].extend(int(m) for m in _DIGITS_RE.findall(line[len("Blocked by:"):]))
144
+ if head and line.startswith("Type:") and cur not in self.hastype:
145
+ self.hastype.add(cur)
146
+ self.typ[cur] = _first_word(line.lower(), "type:")
147
+ if head and line.startswith("Test first:") and cur not in self.hastf:
148
+ self.hastf.add(cur)
149
+ self.tf[cur] = _first_word(line.lower(), "test first:")
150
+ if line.startswith("# ") and cur not in self.hastitle:
151
+ self.hastitle.add(cur)
152
+ self.title[cur] = line[2:]
153
+ if line.startswith("## Done when"):
154
+ self.hasdone[cur] = True
155
+ if line.startswith("## "):
156
+ skipm = re.match(r"^(not in this ticket|answer|review findings)", line[3:].lower()) is not None
157
+ continue
158
+ if not skipm and fnr > 1:
159
+ for seg in _MENTION_RE.finditer(line):
160
+ for num in _DIGITS_RE.findall(seg.group(0)):
161
+ m = int(num)
162
+ if m < 1000 and m not in ment:
163
+ ment.append(m)
164
+
165
+
166
+ def _first_word(lowered, key):
167
+ v = re.sub(r"^%s[ \t]*" % re.escape(key), "", lowered)
168
+ return re.sub(r"[ \t].*$", "", v, flags=re.S)