feature-flow-cli 0.1.3__tar.gz → 0.1.5__tar.gz
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_cli-0.1.3/feature_flow_cli.egg-info → feature_flow_cli-0.1.5}/PKG-INFO +3 -2
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/README.md +2 -1
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/adapters/codex/feature-flow/SKILL.md +3 -1
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow/__init__.py +1 -1
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow/cli.py +10 -3
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow/command.py +14 -2
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow/conductor.py +40 -1
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow/git.py +44 -8
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow/install.py +99 -19
- feature_flow_cli-0.1.5/feature_flow/released.py +90 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow/status.py +2 -1
- feature_flow_cli-0.1.5/feature_flow/suggest.py +23 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5/feature_flow_cli.egg-info}/PKG-INFO +3 -2
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow_cli.egg-info/SOURCES.txt +2 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/guides/build.md +1 -1
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/skills/feature-flow/SKILL.md +3 -1
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/LICENSE +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/MANIFEST.in +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/adapters/codex/feature-flow/agents/openai.yaml +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/agents/feature-planner.md +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/agents/plan-reviewer.md +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/agents/ticket-builder.md +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/agents/ticket-reviewer.md +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow/__main__.py +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow/checks.py +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow/floorguard.py +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow/gate.py +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow/prompts.py +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow/state.py +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow/tickets.py +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow/view.py +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow_cli.egg-info/dependency_links.txt +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow_cli.egg-info/entry_points.txt +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow_cli.egg-info/top_level.txt +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/guides/brief.md +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/guides/plan-review.md +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/guides/plan.md +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/guides/review.md +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/guides/show.md +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/guides/templates/commands.md +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/guides/templates/learnings.md +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/guides/templates/map.md +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/guides/templates/spec.md +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/guides/templates/ticket.md +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/guides/templates/ui-mockup.md +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/install.py +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/install.sh +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/pyproject.toml +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/scripts/floor-guard.py +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/scripts/flow-status.py +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/scripts/flow-view.html +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/scripts/flow-view.py +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/scripts/flow.py +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/scripts/gate.py +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/setup.cfg +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/skills/architect-review/SKILL.md +0 -0
- {feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/skills/automation-design/SKILL.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: feature-flow-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: A ticket-graph workflow for coding agents: installs the feature-flow skill into a repo
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/hamilton-sky/feature-flow
|
|
@@ -53,7 +53,7 @@ uvx feature-flow-cli install /path/to/your/repo --agent all # or: pipx run fea
|
|
|
53
53
|
|
|
54
54
|
The package puts a `feature-flow` command on your PATH with `install`, `status <feature>` and `view <feature>`. It installs the same files, byte for byte, as `install.py` from a clone.
|
|
55
55
|
|
|
56
|
-
The installer lists what it wrote in `.feature-flow/installed.txt`. Commit those files (`git add --pathspec-from-file=.feature-flow/installed.txt`); until you do, the flow does not count them as uncommitted changes.
|
|
56
|
+
The installer lists what it wrote in `.feature-flow/installed.txt`, with each file's hash in `.feature-flow/installed.sha256`. Commit those files (`git add --pathspec-from-file=.feature-flow/installed.txt`); until you do, the flow does not count them as uncommitted changes. Installing a newer version over an older one updates every file nobody edited since it was installed, and keeps (and names) the ones you changed; `--force` replaces those too.
|
|
57
57
|
|
|
58
58
|
Then, in your repo, in the agent:
|
|
59
59
|
|
|
@@ -206,6 +206,7 @@ A session's context fills up, so the flow moves to a fresh session every few tic
|
|
|
206
206
|
|
|
207
207
|
- **One owner at a time.** `start` gives the session a token, and every later call must carry it, so two sessions can never drive the same feature. `HANDOFF` and `DONE` release it.
|
|
208
208
|
- **Resuming after a closed session.** If a session ended without `HANDOFF` (you closed it, it crashed, it ran out of budget), the feature is still owned by it, and a new session stops and says so. Once you are sure the old session is gone, answer yes when the new session asks, or start it with `FLOW_TAKEOVER=1`. With `auto` the skill never takes over on its own.
|
|
209
|
+
- **Stuck on a ticket.** `python3 scripts/flow.py <feature> reset` (or `feature-flow reset <feature>`) reopens a ticket left `claimed` or `in-progress`, commits that, and clears the conductor's run state, so the next `/feature-flow <feature>` starts clean from the ticket files. `reset <NN>` reopens that one ticket, even a resolved one, to build it again. While a session owns the feature it needs `FLOW_TAKEOVER=1`. A mistyped command or feature name gets a `did you mean` hint.
|
|
209
210
|
- **Relay mode.** `FLOW_RELAY=1` hands off after every build and every review, so each phase gets a session of its own.
|
|
210
211
|
- **Nobody needs to watch.** With `auto` the skill asks nothing, and a Claude Code cloud session keeps working while you are away. You only come back to type the `HANDOFF` line.
|
|
211
212
|
|
|
@@ -34,7 +34,7 @@ uvx feature-flow-cli install /path/to/your/repo --agent all # or: pipx run fea
|
|
|
34
34
|
|
|
35
35
|
The package puts a `feature-flow` command on your PATH with `install`, `status <feature>` and `view <feature>`. It installs the same files, byte for byte, as `install.py` from a clone.
|
|
36
36
|
|
|
37
|
-
The installer lists what it wrote in `.feature-flow/installed.txt`. Commit those files (`git add --pathspec-from-file=.feature-flow/installed.txt`); until you do, the flow does not count them as uncommitted changes.
|
|
37
|
+
The installer lists what it wrote in `.feature-flow/installed.txt`, with each file's hash in `.feature-flow/installed.sha256`. Commit those files (`git add --pathspec-from-file=.feature-flow/installed.txt`); until you do, the flow does not count them as uncommitted changes. Installing a newer version over an older one updates every file nobody edited since it was installed, and keeps (and names) the ones you changed; `--force` replaces those too.
|
|
38
38
|
|
|
39
39
|
Then, in your repo, in the agent:
|
|
40
40
|
|
|
@@ -187,6 +187,7 @@ A session's context fills up, so the flow moves to a fresh session every few tic
|
|
|
187
187
|
|
|
188
188
|
- **One owner at a time.** `start` gives the session a token, and every later call must carry it, so two sessions can never drive the same feature. `HANDOFF` and `DONE` release it.
|
|
189
189
|
- **Resuming after a closed session.** If a session ended without `HANDOFF` (you closed it, it crashed, it ran out of budget), the feature is still owned by it, and a new session stops and says so. Once you are sure the old session is gone, answer yes when the new session asks, or start it with `FLOW_TAKEOVER=1`. With `auto` the skill never takes over on its own.
|
|
190
|
+
- **Stuck on a ticket.** `python3 scripts/flow.py <feature> reset` (or `feature-flow reset <feature>`) reopens a ticket left `claimed` or `in-progress`, commits that, and clears the conductor's run state, so the next `/feature-flow <feature>` starts clean from the ticket files. `reset <NN>` reopens that one ticket, even a resolved one, to build it again. While a session owns the feature it needs `FLOW_TAKEOVER=1`. A mistyped command or feature name gets a `did you mean` hint.
|
|
190
191
|
- **Relay mode.** `FLOW_RELAY=1` hands off after every build and every review, so each phase gets a session of its own.
|
|
191
192
|
- **Nobody needs to watch.** With `auto` the skill asks nothing, and a Claude Code cloud session keeps working while you are away. You only come back to type the `HANDOFF` line.
|
|
192
193
|
|
|
@@ -31,7 +31,7 @@ To add tickets to a plan that exists, edit it by hand following the ticket rules
|
|
|
31
31
|
|
|
32
32
|
With a plan present, check these before `start`, and stop at the first that fails:
|
|
33
33
|
|
|
34
|
-
- `git status --porcelain` is empty, apart from untracked Python bytecode (`__pycache__/`, `*.pyc`) and
|
|
34
|
+
- `git status --porcelain` is empty, apart from untracked Python bytecode (`__pycache__/`, `*.pyc`) and files listed in `.feature-flow/installed.txt` and that list itself: those are feature-flow's own install or upgrade, so never stop for them (`next` still stops if one of them was edited). Suggest committing them (`git add --pathspec-from-file=.feature-flow/installed.txt && git commit -m "chore: install feature-flow"`) and carry on.
|
|
35
35
|
- `python3 scripts/flow-status.py <feature> --check` prints `OK`.
|
|
36
36
|
- `.agents/flow-roles/ticket-builder.md` and `.agents/flow-roles/ticket-reviewer.md` exist. If not, say to run `uvx feature-flow-cli install . --agent codex` in this repo (or `python3 install.py <repo> --agent codex` from a feature-flow clone).
|
|
37
37
|
|
|
@@ -39,6 +39,8 @@ Then run `FLOW_INVOKE='$feature-flow' python3 scripts/flow.py <feature> start`.
|
|
|
39
39
|
|
|
40
40
|
If `start` prints `STOP` naming another owner, another session may still be working this feature. Ask the user whether that session is closed. Only on a clear yes, run `start` once more with `FLOW_TAKEOVER=1`. In `auto` mode, never take over: report and stop.
|
|
41
41
|
|
|
42
|
+
If the user says the flow is stuck on a ticket or asks for a reset, run `reset` (or `reset <NN>` to redo one ticket): it reopens a half-built ticket, commits that, and clears the run, then run `start` again. It needs `FLOW_TAKEOVER=1` while a session owns the feature, on the same clear yes. Never reset on your own.
|
|
43
|
+
|
|
42
44
|
Say what will happen: for each ticket, a builder subagent and then a reviewer subagent. After every few tickets (`FLOW_TICKETS_PER_SESSION`, default 4) you hand off to a new session. Ask for a yes, unless `auto` was given.
|
|
43
45
|
|
|
44
46
|
## The loop
|
|
@@ -3,18 +3,23 @@
|
|
|
3
3
|
import sys
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
|
+
from feature_flow import suggest
|
|
6
7
|
from feature_flow.conductor import Conductor, NoPhase, Stop
|
|
7
8
|
|
|
8
|
-
USAGE = ("usage: python3 scripts/flow.py <feature> start | next | prompt | verdict <file
|
|
9
|
+
USAGE = ("usage: python3 scripts/flow.py <feature> start | next | prompt | verdict <file> | reset [NN]\n"
|
|
9
10
|
" python3 scripts/flow.py <feature> plan-prompt <brief> [findings] | plan-review-prompt | plan-accept")
|
|
10
|
-
COMMANDS = ("start", "next", "prompt", "verdict", "plan-prompt", "plan-review-prompt", "plan-accept")
|
|
11
|
-
ARGS = {"verdict": (3,), "plan-prompt": (3, 4)}
|
|
11
|
+
COMMANDS = ("start", "next", "prompt", "verdict", "plan-prompt", "plan-review-prompt", "plan-accept", "reset")
|
|
12
|
+
ARGS = {"verdict": (3,), "plan-prompt": (3, 4), "reset": (2, 3)}
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
def main(argv=None, scripts=None):
|
|
15
16
|
args = list(sys.argv[1:] if argv is None else argv)
|
|
16
17
|
if len(args) < 2 or args[1] not in COMMANDS or not args[0] or args[0].startswith("-"):
|
|
17
18
|
print(USAGE, file=sys.stderr)
|
|
19
|
+
if len(args) >= 2 and args[0] in COMMANDS:
|
|
20
|
+
sys.stderr.write("did you mean: flow.py %s %s?\n" % (args[1], args[0]))
|
|
21
|
+
elif len(args) >= 2:
|
|
22
|
+
sys.stderr.write(suggest.hint(args[1], COMMANDS))
|
|
18
23
|
return 2
|
|
19
24
|
feature, command = args[0], args[1]
|
|
20
25
|
if feature in (".", "..") or any(c in feature for c in "/\\:"):
|
|
@@ -37,6 +42,8 @@ def main(argv=None, scripts=None):
|
|
|
37
42
|
line = conductor.plan_accept()
|
|
38
43
|
elif command == "prompt":
|
|
39
44
|
line = conductor.prompt()
|
|
45
|
+
elif command == "reset":
|
|
46
|
+
line = conductor.reset(args[2] if len(args) == 3 else None)
|
|
40
47
|
elif command == "start":
|
|
41
48
|
line = conductor.start()
|
|
42
49
|
else:
|
|
@@ -7,18 +7,25 @@ python3 scripts/flow.py, with the copy the installer put there.
|
|
|
7
7
|
import os
|
|
8
8
|
import sys
|
|
9
9
|
|
|
10
|
-
from
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from feature_flow import __version__, cli, install, status, suggest, view
|
|
11
13
|
|
|
12
14
|
USAGE = """\
|
|
13
15
|
usage: feature-flow install [target-repo] [--agent claude|codex|all] [--user] [--force] [--dry-run]
|
|
14
16
|
feature-flow status <feature> [--next | --counts | --check | --mermaid [plain] | --json]
|
|
15
17
|
feature-flow view <feature> [--watch] [--no-open] [--out FILE]
|
|
18
|
+
feature-flow reset <feature> [NN]
|
|
16
19
|
feature-flow --version
|
|
17
20
|
install copies the skill, its roles, scripts and guides into a repo; feature-flow install --help says more.
|
|
18
21
|
status and view read plans/<feature>/ in the current directory, like scripts/flow-status.py and scripts/flow-view.py.
|
|
22
|
+
reset reopens a half-built ticket (or ticket NN), commits that, and clears the run, like scripts/flow.py <feature> reset.
|
|
19
23
|
"""
|
|
20
24
|
|
|
21
25
|
|
|
26
|
+
COMMANDS = ("install", "status", "view", "reset", "--version", "--help")
|
|
27
|
+
|
|
28
|
+
|
|
22
29
|
def main(argv=None):
|
|
23
30
|
args = list(sys.argv[1:] if argv is None else argv)
|
|
24
31
|
if not args or args[0] in ("-h", "--help", "help"):
|
|
@@ -35,5 +42,10 @@ def main(argv=None):
|
|
|
35
42
|
return status.main(rest)
|
|
36
43
|
if command == "view":
|
|
37
44
|
return view.main(rest, here=os.path.join(install.source_root(), "scripts"))
|
|
38
|
-
|
|
45
|
+
if command == "reset":
|
|
46
|
+
if not rest or len(rest) > 2:
|
|
47
|
+
sys.stderr.write(USAGE)
|
|
48
|
+
return 2
|
|
49
|
+
return cli.main(rest[:1] + ["reset"] + rest[1:], scripts=Path.cwd() / "scripts")
|
|
50
|
+
sys.stderr.write("unknown command: %s\n%s%s" % (command, suggest.hint(command, COMMANDS), USAGE))
|
|
39
51
|
return 2
|
|
@@ -10,7 +10,7 @@ import secrets
|
|
|
10
10
|
import shutil
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
|
-
from feature_flow import checks, git, prompts, state, tickets
|
|
13
|
+
from feature_flow import checks, git, prompts, state, suggest, tickets
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
VERDICT = re.compile(r"^REVIEW: (PASS|FAIL)[ \t\r\f\v]*$")
|
|
@@ -254,6 +254,45 @@ class Conductor:
|
|
|
254
254
|
return self.judge_review()
|
|
255
255
|
return self.pick_ticket()
|
|
256
256
|
|
|
257
|
+
def reset(self, num=None):
|
|
258
|
+
"""Put the feature back to a clean start: reopen a half-built ticket (or ticket num), commit that,
|
|
259
|
+
and forget the run. The tickets stay the truth; only the conductor's note and counters go."""
|
|
260
|
+
if not self.plan.is_dir():
|
|
261
|
+
hint = suggest.hint(self.feature, suggest.features(self.root)).strip()
|
|
262
|
+
raise Stop("no plan folder %s%s" % (self.plan, ". " + hint if hint else ""))
|
|
263
|
+
owner = self.get("owner")
|
|
264
|
+
if owner and os.environ.get("FLOW_TAKEOVER", "0") != "1":
|
|
265
|
+
raise Stop("%s is owned by session %s. if that session is closed or dead, run reset with FLOW_TAKEOVER=1"
|
|
266
|
+
% (self.feature, owner))
|
|
267
|
+
paths = tickets.ticket_files(str(self.plan / (os.environ.get("FLOW_TICKETS") or "tasks")))
|
|
268
|
+
if num is not None:
|
|
269
|
+
chosen = [p for p in paths if tickets.number(p) == num.zfill(2)]
|
|
270
|
+
if not chosen:
|
|
271
|
+
raise Stop("no ticket %s in %s" % (num, self.plan))
|
|
272
|
+
else:
|
|
273
|
+
chosen = [p for p in paths if tickets.status(p) in tickets.RESET]
|
|
274
|
+
reopened = []
|
|
275
|
+
for path in chosen:
|
|
276
|
+
if tickets.status(path) != "open":
|
|
277
|
+
tickets.set_open(path)
|
|
278
|
+
reopened.append(path)
|
|
279
|
+
if reopened:
|
|
280
|
+
nums = ", ".join(tickets.number(p) for p in reopened)
|
|
281
|
+
if not git.commit_paths(reopened, "chore(%s): reset %s to open" % (self.feature, nums)):
|
|
282
|
+
raise Stop("reopened %s but could not commit it. this session must be allowed to run git commit"
|
|
283
|
+
% nums)
|
|
284
|
+
had_run = self.state_file.is_file()
|
|
285
|
+
for path in (self.state_file, self.findings_file):
|
|
286
|
+
if path.is_file():
|
|
287
|
+
path.unlink()
|
|
288
|
+
if not reopened and not had_run:
|
|
289
|
+
return "OK nothing to reset for %s. run %s %s" % (self.feature, self.invoke, self.feature)
|
|
290
|
+
self.st = {}
|
|
291
|
+
self.log("RESET")
|
|
292
|
+
done = ["cleared the run state"] if had_run else []
|
|
293
|
+
done += ["reopened %s" % tickets.name(p) for p in reopened]
|
|
294
|
+
return "OK %s. run %s %s" % (", ".join(done), self.invoke, self.feature)
|
|
295
|
+
|
|
257
296
|
def judge_review(self):
|
|
258
297
|
if not git.tracked_clean() or git.head() != self.get("review_sha"):
|
|
259
298
|
raise Stop("the reviewer changed tracked files, which a reviewer must never do")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""The few git calls the conductor needs, each one an argument list, never a shell string."""
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import subprocess
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
@@ -30,17 +31,30 @@ def head():
|
|
|
30
31
|
return _git("rev-parse", "HEAD").stdout.strip()
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
# the installer's record of what it wrote: the paths, and the sha256 of each as it wrote it
|
|
35
|
+
INSTALLED = Path(".feature-flow") / "installed.txt"
|
|
36
|
+
HASHES = Path(".feature-flow") / "installed.sha256"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_lines(path):
|
|
40
|
+
try:
|
|
41
|
+
return path.read_text(encoding="utf-8").splitlines()
|
|
42
|
+
except (OSError, UnicodeDecodeError):
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
|
|
33
46
|
def changes():
|
|
34
47
|
"""Every changed path, untracked files included, as `git status --porcelain` sees them, except
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
48
|
+
feature-flow's own install: a file still exactly as the installer wrote it, an untracked file it
|
|
49
|
+
lists without a hash (an install from before it kept hashes), and its two record files, which every
|
|
50
|
+
install rewrites; and untracked Python bytecode, which the flow's own Python runs leave behind."""
|
|
51
|
+
listed, hashes = set(), {}
|
|
38
52
|
top = toplevel()
|
|
39
53
|
if top is not None:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
54
|
+
listed = set(_read_lines(top / INSTALLED))
|
|
55
|
+
for line in _read_lines(top / HASHES):
|
|
56
|
+
digest, _, name = line.partition(" ")
|
|
57
|
+
hashes[name] = digest
|
|
44
58
|
entries = _git("status", "--porcelain", "-z", "-uall").stdout.split("\0")
|
|
45
59
|
found = []
|
|
46
60
|
i = 0
|
|
@@ -52,12 +66,26 @@ def changes():
|
|
|
52
66
|
code, path = entry[:2], entry[3:]
|
|
53
67
|
if code[0] in "RC":
|
|
54
68
|
i += 1 # the rename's source path follows
|
|
55
|
-
if
|
|
69
|
+
if path in (INSTALLED.as_posix(), HASHES.as_posix()):
|
|
70
|
+
continue
|
|
71
|
+
if path in hashes:
|
|
72
|
+
# only a worktree change: a staged version would ride along in the next commit
|
|
73
|
+
if (code[0] in " ?" and code[1] != "T" and top is not None and not (top / path).is_symlink()
|
|
74
|
+
and _sha256(top / path) == hashes[path]):
|
|
75
|
+
continue
|
|
76
|
+
elif code == "??" and (path in listed or _bytecode(path)):
|
|
56
77
|
continue
|
|
57
78
|
found.append(path)
|
|
58
79
|
return found
|
|
59
80
|
|
|
60
81
|
|
|
82
|
+
def _sha256(path):
|
|
83
|
+
try:
|
|
84
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
85
|
+
except OSError:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
61
89
|
def _bytecode(path):
|
|
62
90
|
return path.endswith(".pyc") or "__pycache__/" in "/" + path
|
|
63
91
|
|
|
@@ -72,6 +100,14 @@ def tracked_clean():
|
|
|
72
100
|
and _git("diff", "--cached", "--quiet", check=False).returncode == 0)
|
|
73
101
|
|
|
74
102
|
|
|
103
|
+
def commit_paths(paths, message):
|
|
104
|
+
"""Commit these paths only, whatever else is staged."""
|
|
105
|
+
paths = [str(p) for p in paths]
|
|
106
|
+
if _git("add", "--", *paths, check=False).returncode != 0:
|
|
107
|
+
return False
|
|
108
|
+
return _git("commit", "-q", "-m", message, "--", *paths, check=False).returncode == 0
|
|
109
|
+
|
|
110
|
+
|
|
75
111
|
def commit_file(path, message):
|
|
76
112
|
if _git("add", "--", str(path), check=False).returncode != 0:
|
|
77
113
|
return False
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
usage: python3 install.py [target-repo] [--agent claude|codex|all] [--user] [--force] [--dry-run]
|
|
4
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
|
|
5
|
+
a file that already exists and differs is kept and reported, unless --force is given or nobody
|
|
6
|
+
edited it since an earlier feature-flow install wrote it (then it is updated).
|
|
6
7
|
nothing is ever deleted. CLAUDE_HOME overrides ~/.claude and AGENTS_HOME overrides ~/.agents for --user.
|
|
7
8
|
|
|
8
9
|
Files are compared and written as bytes, and the two text transforms (the Codex skill header and
|
|
@@ -11,12 +12,15 @@ so the installed tree is the same byte for byte. Files are listed in byte order,
|
|
|
11
12
|
with LC_ALL=C.
|
|
12
13
|
"""
|
|
13
14
|
|
|
15
|
+
import hashlib
|
|
14
16
|
import os
|
|
15
17
|
import re
|
|
16
18
|
import shutil
|
|
17
19
|
import subprocess
|
|
18
20
|
import sys
|
|
19
21
|
|
|
22
|
+
from feature_flow.released import RELEASED
|
|
23
|
+
|
|
20
24
|
# the help text
|
|
21
25
|
USAGE = """\
|
|
22
26
|
usage: python3 install.py [target-repo] [--agent claude|codex|all] [--user] [--force] [--dry-run]
|
|
@@ -30,7 +34,8 @@ copies the feature-flow skill, its roles, scripts and guides into a repo so the
|
|
|
30
34
|
--agent all does both.
|
|
31
35
|
scripts go to <target>/scripts/ and guides, roles and the Python package to <target>/.feature-flow/
|
|
32
36
|
(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
|
|
37
|
+
a file that already exists and differs is kept and reported, unless --force is given or it is
|
|
38
|
+
still exactly what an earlier feature-flow install wrote (then it is updated).
|
|
34
39
|
nothing is ever deleted. CLAUDE_HOME overrides ~/.claude and AGENTS_HOME overrides ~/.agents for --user.
|
|
35
40
|
"""
|
|
36
41
|
|
|
@@ -42,6 +47,16 @@ SKILLS = ("feature-flow", "architect-review", "automation-design")
|
|
|
42
47
|
# the in-repo files an install wrote, one path per line, relative to the target. The conductor does not
|
|
43
48
|
# count them as a dirty tree while they are untracked, and `git add --pathspec-from-file` commits them.
|
|
44
49
|
INSTALLED = ".feature-flow/installed.txt"
|
|
50
|
+
# the sha256 of each of those files as the install wrote it, `<sha256> <path>` per line. A later install
|
|
51
|
+
# replaces a file that still has its recorded bytes: nobody edited it, so it is not the user's version.
|
|
52
|
+
HASHES = ".feature-flow/installed.sha256"
|
|
53
|
+
# the same record for a --user install, in ~/.claude and ~/.agents, paths relative to that folder
|
|
54
|
+
USER_HASHES = "feature-flow.sha256"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _sha256(data):
|
|
58
|
+
return hashlib.sha256(data).hexdigest()
|
|
59
|
+
|
|
45
60
|
|
|
46
61
|
# an old skill that runs the ticket script (the bash one or the Python one) or ends a review with a verdict
|
|
47
62
|
LEFTOVER = re.compile(rb"scripts/flow-status\.(?:sh|py)|REVIEW: PASS")
|
|
@@ -172,7 +187,14 @@ class Installer:
|
|
|
172
187
|
package = os.path.dirname(os.path.abspath(__file__))
|
|
173
188
|
self.package = package
|
|
174
189
|
self.added = self.updated = self.same = self.kept = 0
|
|
175
|
-
self.written =
|
|
190
|
+
self.written = {}
|
|
191
|
+
# each folder this installs into, with its hash record; the target's own comes first
|
|
192
|
+
self.records = [(target, HASHES)]
|
|
193
|
+
if user_level and agent in ("claude", "all"):
|
|
194
|
+
self.records.append((self.claude_dir, USER_HASHES))
|
|
195
|
+
if user_level and agent in ("codex", "all"):
|
|
196
|
+
self.records.append((self.agents_dir, USER_HASHES))
|
|
197
|
+
self.recorded = dict((root, self._read_hashes(root + "/" + record)) for root, record in self.records)
|
|
176
198
|
|
|
177
199
|
def place(self, file, dest, data=None):
|
|
178
200
|
"""One file: add it, replace it (--force), leave it alone (same) or keep the user's version.
|
|
@@ -181,23 +203,55 @@ class Installer:
|
|
|
181
203
|
if not os.path.exists(dest):
|
|
182
204
|
self.out(" add " + dest)
|
|
183
205
|
self.added += 1
|
|
184
|
-
self.written
|
|
206
|
+
self.written[dest] = new
|
|
185
207
|
if not self.dry:
|
|
186
208
|
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
187
209
|
self._copy(file, dest, data)
|
|
188
210
|
elif new is not None and _read(dest) == new:
|
|
189
211
|
self.same += 1
|
|
190
|
-
self.written
|
|
191
|
-
elif self.force:
|
|
212
|
+
self.written[dest] = new
|
|
213
|
+
elif self.force or self.untouched(dest):
|
|
192
214
|
self.out(" update " + dest)
|
|
193
215
|
self.updated += 1
|
|
194
|
-
self.written
|
|
216
|
+
self.written[dest] = new
|
|
195
217
|
if not self.dry:
|
|
196
218
|
self._copy(file, dest, data)
|
|
197
219
|
else:
|
|
198
220
|
self.out(" kept " + dest + " (differs from this version, use --force to replace it)")
|
|
199
221
|
self.kept += 1
|
|
200
222
|
|
|
223
|
+
def locate(self, dest):
|
|
224
|
+
"""The install folder dest is in and dest relative to it, or (None, None)."""
|
|
225
|
+
for root, _ in sorted(self.records, key=lambda r: -len(r[0])):
|
|
226
|
+
if dest.startswith(root + "/"):
|
|
227
|
+
return root, dest[len(root) + 1:]
|
|
228
|
+
return None, None
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def _read_hashes(path):
|
|
232
|
+
recorded = {}
|
|
233
|
+
try:
|
|
234
|
+
with open(path, encoding="utf-8") as f:
|
|
235
|
+
for line in f:
|
|
236
|
+
digest, _, name = line.rstrip("\n").partition(" ")
|
|
237
|
+
if name:
|
|
238
|
+
recorded[name] = digest
|
|
239
|
+
except (OSError, UnicodeDecodeError):
|
|
240
|
+
pass
|
|
241
|
+
return recorded
|
|
242
|
+
|
|
243
|
+
def untouched(self, dest):
|
|
244
|
+
"""dest still holds the bytes an earlier feature-flow install wrote there. Never a symlink or a
|
|
245
|
+
file with other hard links: writing through it would change the file it shares bytes with."""
|
|
246
|
+
if os.path.islink(dest) or os.stat(dest).st_nlink > 1:
|
|
247
|
+
return False
|
|
248
|
+
old = _read(dest)
|
|
249
|
+
if old is None:
|
|
250
|
+
return False
|
|
251
|
+
digest = _sha256(old)
|
|
252
|
+
root, rel = self.locate(dest)
|
|
253
|
+
return digest in RELEASED or self.recorded.get(root, {}).get(rel) == digest
|
|
254
|
+
|
|
201
255
|
@staticmethod
|
|
202
256
|
def _copy(file, dest, data):
|
|
203
257
|
"""cp -p: contents, mode and times."""
|
|
@@ -243,21 +297,48 @@ class Installer:
|
|
|
243
297
|
role_body(data if data is not None else b""))
|
|
244
298
|
|
|
245
299
|
def write_installed(self):
|
|
246
|
-
"""Add this run's in-repo files to the list,
|
|
247
|
-
|
|
300
|
+
"""Add this run's in-repo files to the list, and every file's hash to its folder's record, keeping
|
|
301
|
+
the earlier runs' entries for files that still exist. True when the target's list or record changed."""
|
|
248
302
|
names = set()
|
|
249
303
|
try:
|
|
250
|
-
with open(
|
|
304
|
+
with open(self.target + "/" + INSTALLED, encoding="utf-8") as old:
|
|
251
305
|
names.update(line.strip() for line in old)
|
|
252
306
|
except OSError:
|
|
253
307
|
pass
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
308
|
+
hashes = dict((root, dict(self.recorded[root])) for root, _ in self.records)
|
|
309
|
+
for dest, data in self.written.items():
|
|
310
|
+
root, rel = self.locate(dest)
|
|
311
|
+
if root == self.target:
|
|
312
|
+
names.add(rel)
|
|
313
|
+
if root is not None and data is not None:
|
|
314
|
+
hashes[root][rel] = _sha256(data)
|
|
315
|
+
names.update((INSTALLED, HASHES))
|
|
316
|
+
names = sorted(n for n in names
|
|
317
|
+
if n in (INSTALLED, HASHES) or (n and os.path.isfile(self.target + "/" + n)))
|
|
318
|
+
changed = self._write(self.target + "/" + INSTALLED, "".join(n + "\n" for n in names))
|
|
319
|
+
for root, record in self.records:
|
|
320
|
+
kept = names if root == self.target else sorted(
|
|
321
|
+
n for n in hashes[root] if os.path.isfile(root + "/" + n))
|
|
322
|
+
text = "".join("%s %s\n" % (hashes[root][n], n) for n in kept if n in hashes[root])
|
|
323
|
+
if self._write(root + "/" + record, text) and root == self.target:
|
|
324
|
+
changed = True
|
|
325
|
+
return changed
|
|
326
|
+
|
|
327
|
+
@staticmethod
|
|
328
|
+
def _write(path, text):
|
|
329
|
+
try:
|
|
330
|
+
with open(path, encoding="utf-8", newline="") as f:
|
|
331
|
+
if f.read() == text:
|
|
332
|
+
return False
|
|
333
|
+
except (OSError, UnicodeDecodeError):
|
|
334
|
+
pass
|
|
258
335
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
259
|
-
|
|
260
|
-
|
|
336
|
+
# a new file moved into place: a link at the path is replaced, never written through
|
|
337
|
+
tmp = path + ".tmp"
|
|
338
|
+
with open(tmp, "w", encoding="utf-8", newline="\n") as f:
|
|
339
|
+
f.write(text)
|
|
340
|
+
os.replace(tmp, path)
|
|
341
|
+
return True
|
|
261
342
|
|
|
262
343
|
def report_leftovers(self, dirs):
|
|
263
344
|
"""A skill folder this installer does not own but that drives the flow (it runs the ticket
|
|
@@ -294,8 +375,7 @@ class Installer:
|
|
|
294
375
|
self.copy_tree(here + "/guides", self.target + "/.feature-flow/guides")
|
|
295
376
|
self.copy_tree(here + "/agents", self.target + "/.feature-flow/agents")
|
|
296
377
|
self.copy_tree(self.package, self.target + "/.feature-flow/feature_flow", skip=BUNDLE)
|
|
297
|
-
|
|
298
|
-
self.write_installed()
|
|
378
|
+
listed = not self.dry and self.write_installed()
|
|
299
379
|
|
|
300
380
|
self.out("")
|
|
301
381
|
verb = "would add" if self.dry else "added"
|
|
@@ -311,7 +391,7 @@ class Installer:
|
|
|
311
391
|
git = 127
|
|
312
392
|
if git != 0:
|
|
313
393
|
self.out("note: %s is not a git repository, and the flow needs one" % self.target)
|
|
314
|
-
elif self.added or self.updated:
|
|
394
|
+
elif self.added or self.updated or listed:
|
|
315
395
|
git_c = ""
|
|
316
396
|
if os.path.realpath(self.target) != os.path.realpath(os.getcwd()):
|
|
317
397
|
git_c = '-C "%s" ' % self.target if " " in self.target else "-C %s " % self.target
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""The sha256 of every file that feature-flow-cli 0.1.0 to 0.1.4 installed (all three --agent
|
|
2
|
+
choices; .feature-flow/installed.txt aside). Those releases kept no record of what they wrote, so an
|
|
3
|
+
install over one of them replaces a file whose bytes are on this list: nobody edited it since. Later
|
|
4
|
+
installs record their own hashes in .feature-flow/installed.sha256, so this list never grows."""
|
|
5
|
+
|
|
6
|
+
RELEASED = frozenset((
|
|
7
|
+
"03111666084be6a375a0e7a42784ea8c5aed7a5a1e71e83cf2ad44fd656f758c",
|
|
8
|
+
"0385988f5d4a23daf13556a391b71bb387a25ab2b7fa8bba81b0276321028e07",
|
|
9
|
+
"0597a52bee02482ee472218b98ee6c507900df02c4f081ca795b0038eebc21bd",
|
|
10
|
+
"08419881ab53935e0a739d8ae7fc6e5cdfaeb597394bdd5c11d538a5a8086091",
|
|
11
|
+
"0b2544121889d10f5b3295700ed49d03940291243edbe6f69b5cc9eb5c2678db",
|
|
12
|
+
"0fbc776b32836e8050c07cd95d50265a415999f558c00bc7131a8b1c8f8dd893",
|
|
13
|
+
"116af2cb64dd22b061e7217a7aec96ecc2fa9193905f17154ce73542b8a3a049",
|
|
14
|
+
"158be4902bdd7a039c9fd844188545c148e289c8ca168a0a93890b9321342252",
|
|
15
|
+
"1598ed3851313756552c258447b74a7b59aca36365760ce66ecd0695a8614021",
|
|
16
|
+
"16e75f565e30ac0d4e418d813f9c399928607a26a7c84f4b983bb1e8fc4e30c1",
|
|
17
|
+
"1bdbe5d780580a9931d123decccdf5c06293b11e3481658dbfe50a8511326b85",
|
|
18
|
+
"1ee3b01e4c92e9fa9f8124a18fa89ea336562d2597325201ec0595ee431f0c09",
|
|
19
|
+
"2a3416153494015130e81876ee94f452a9c94848757cff488309ac056048887a",
|
|
20
|
+
"2a799ddadcfc4744c0837cb0a087f74556cdb88945be93ed8e508a5667c1f65a",
|
|
21
|
+
"2ae68f7df0e92d59a9e69dbd3da5103da9ab3de9b9a2ab7aec3054c410cb5ad1",
|
|
22
|
+
"2c6350ef4162ee3d208145821818c4540a99dcf4bb17915f0fdfca8a89e01bfb",
|
|
23
|
+
"2d2666ff07b3bafa9b257b013a1ef5f889b95c6657cc553f580bc74ef1232038",
|
|
24
|
+
"2f5b589e2acafc5bfefbcbf68d69bc57c028dc509d4766160762695be532aa9b",
|
|
25
|
+
"2f94fd1730077974774efa75d49741a6277e9a060041041c023ff9ff4d6129e7",
|
|
26
|
+
"38901e898c4f78b41527dede20bfca26080de99683b3e5bba0019d87046ae338",
|
|
27
|
+
"38b2d2b4c45e8c76ecd0381ef0820ca4cc516c4ac1f117b2ec99ac6ce3c2e300",
|
|
28
|
+
"39d97eb189726b3c92a399dc60e736d12b12a8252d637588c5b2a31fc2020b5f",
|
|
29
|
+
"3ff57472860da0076ac1d8a8a0537eddd29f3c63989cd5068906e5f31e7abebc",
|
|
30
|
+
"408779e5a420a8b90cf0422830660f1593efdd9c4999665a1beb0097219c2749",
|
|
31
|
+
"447f0c9f2eccb2f5313ca913d91d7fb9c336b30fa03b5d534609cd22de040703",
|
|
32
|
+
"45b62b9d60f88ef8ac8f22f585d662a3004b1dce3875f125b5a1649958511392",
|
|
33
|
+
"45cc664cb168ea82b3d04d16cd96c4392bcc21741f55749306e69fd8f2a0f68c",
|
|
34
|
+
"46ad183898dfa8d860480a123049a74c1bae56d364434ddcd51ed955fec02ca1",
|
|
35
|
+
"4bb17e6f9d9b3c77e858f061b888ab9a7bea9e3ef2cf269230c1c3d42d800a91",
|
|
36
|
+
"52c8362a637ea80835743fd568bce4c05c591d758608c5b89316391779b0166b",
|
|
37
|
+
"569951f6d447ae256d1802ab29ac287af6858e38a34879d3f4534d732097babc",
|
|
38
|
+
"5a2b9d23bbc192412b574d96cc26c09e486173c4ecf3b1d72e4f989f600bbaa9",
|
|
39
|
+
"5b4611c7d419c54d645189cf3e67630678b10d5300f7a6dbe32f75d87876dbf4",
|
|
40
|
+
"5e3895c34a7a0a95cd784c3c99beab4e102bc0a84edd76a9e7586007f55df16b",
|
|
41
|
+
"631fd0f9243caca088bf8ff37a08c1f5e75bcd42589b73a18137936259d211b7",
|
|
42
|
+
"6981a0e0de889d7296bc680f030adf3e354785424d589548257863ca6ae82835",
|
|
43
|
+
"69b13a131466af54a172e7255fade246aad706b177fa3601a843af7a873cb226",
|
|
44
|
+
"6b559ad3ea84b358417700469b7aa0aa48079fc6cddcc9fcbc2dbe312a6ccec8",
|
|
45
|
+
"71c83d16835bf50ee9e78a4a4183a101a1124d5059390e219b7509c96b70488c",
|
|
46
|
+
"71d26991a5263aade812bc9aaf90ddbee44b9c61263302fa5fbcf0093f74be6b",
|
|
47
|
+
"7530aa90149353b607560eb9c16a8ce9be75846f68fb3b71c5a2c9173d21a9b3",
|
|
48
|
+
"761710d8db4ffcb3c9c1a3d941210fa052569994bc2138e974ad44cc6ffe6ba3",
|
|
49
|
+
"774116e163377390b58dc779d008825013d4aff023a2c30332f46db53b473edd",
|
|
50
|
+
"7e42e219a8bb55cad10239ffa290a3d12912350635e8c846c105888dfada5670",
|
|
51
|
+
"800e3a2518755ca20f64ba8518943a11f60e14c8e0ec77bc7a631b7660968861",
|
|
52
|
+
"80535d95ae9fb56ddf746240e9ce6fcf6eaac401e0516ac37584b411113b7017",
|
|
53
|
+
"89522ebb5bf8891a5faab80c86715e3f610808ef588ce0bb261c5ba4bb83c115",
|
|
54
|
+
"8cfd3af473fa146477117cbc406841c542bc137516951fb801204395b444233b",
|
|
55
|
+
"9551f7f94fed0f7e1bb6f35dc6826e2eec37ae18f840ac741fbf369c0fb0bfe7",
|
|
56
|
+
"983395967e1e26bca59cf4da539481f426c3c3d02e67028076cd4d204527ac44",
|
|
57
|
+
"9892816825e0de4f5a40dbaf2506b30d1b3568d0aeadc6597c4a1fc0fd872225",
|
|
58
|
+
"9de945fd57f367ae4db739d8a63d45996912f3797450b53f3ad04f8c43f5cbd5",
|
|
59
|
+
"a0b250ed64f1d38dd9029507a8a34fdbd2bc3aa78a930c439029889f6acfd89f",
|
|
60
|
+
"a666d3eba10f164f50a5005ef70c19b1d5f33c3d43fdf926ac7a9a6815514366",
|
|
61
|
+
"a6d0a0b7ba5c5ccedf2a4d27e0b06e61b8e9563e81fdcd7617da9d4bc3506d71",
|
|
62
|
+
"a7871218beabd7928dc63729cd5141539dc291bb037baa1b9c82e8269c0e272a",
|
|
63
|
+
"aafd905d9d98879c7b6747aa544542df23f6d565cdcef47f7a207808983900d9",
|
|
64
|
+
"ae0790f7fca8c996c3844827e9904ee95b2ced3854277bd440faa59213603935",
|
|
65
|
+
"ae999c50a91a234b4943dff8731d40d30da7d04b2ba4cf13911a6ac6236d2c60",
|
|
66
|
+
"af9a25625f44a64770d6aebce5882c08dbda89b116a25d430a1b7855b8b0d9cf",
|
|
67
|
+
"b922951702a7837923fb52820930ca32fc96c13fc1f0a44650ee135828b4aa80",
|
|
68
|
+
"bc2f066334afbee46bd97168513c36ae129a098179c389c1986b0b7a47f98621",
|
|
69
|
+
"bd24f9b858bab7411493eb97a2c011d906ec56d7a7efb2c7e116cc308b0e4c55",
|
|
70
|
+
"bd7a2653a1015b9e42366c21a2dd60d1c4ea3ca07ca0afee9dddf3d36ec7741e",
|
|
71
|
+
"bda718088ea2b848b000dca2ff78e35795d4400b57ecbc0bfbccdab451245f45",
|
|
72
|
+
"c0a28ab04d02c5eff6f94ed3cdc543257919adcaaf30ec108cd6692a5319081c",
|
|
73
|
+
"c2dbc3162f8bd47a15cb0a10e6cade2cefd3f951ea8c75b175957be1cdceb174",
|
|
74
|
+
"c35e773600d4c3340b5e49eff9cc8eb2682adf7ab8a56599a2ce3fa709c10cbf",
|
|
75
|
+
"c36d2575c40b7f71abc45a58124e6a18b6f423919b9bc1ec69a1a1ff0095589d",
|
|
76
|
+
"c3a0196ebb59f110e968173ff40df3778e73e7eded2afe58deea9dc0734a9e20",
|
|
77
|
+
"cf78e2cfe6e224e3854017787634bffc3e048b48709b767b7ae953e20b707746",
|
|
78
|
+
"d3a2313826088d95dc0bc2625b0222f98223636eda3a59efeac5838f362e5b2b",
|
|
79
|
+
"e0a98bc037bfdef778a0d5d14a4d9abc2b23c4c0ea754491cba307e9c0e2169f",
|
|
80
|
+
"e8194c0aa46b169d8412837f9345c8f32378570b8474a2357c5570080093b7ce",
|
|
81
|
+
"eb346f05cd6359292749a0915fe8b3412b3f99a493a21b294e763a3da0a80852",
|
|
82
|
+
"ee7c8f100721d6ad36ad653a0b82c11eab02217f015344bc707c158f32a08c93",
|
|
83
|
+
"f05d890a79379ee8b89bcef86361acc93ca6b3f7b277500126d058243a60f9f2",
|
|
84
|
+
"f335488f1e89e63bf2f0588a71feccbfece080ef9dcb5fac15f89868576434f0",
|
|
85
|
+
"f34612dfc665fccc1a47f041d096877fa8a4d9c00aad832646d9ab6408305bba",
|
|
86
|
+
"f6352f7ca92571045aa0c7d8ffbe2708f8315c6bd852433e7f32fc8b4af65fdb",
|
|
87
|
+
"f714136a9c949bcb23b67755a3072d0946ca5a6c0a484f6a42f80c23964e5758",
|
|
88
|
+
"f799e5372b598cda13cf2b2d8b888c1703fed71a3af2ec2aa802b838a7de39ba",
|
|
89
|
+
"fded1be6a891c062ad84d14960b47daa0d2424d87ade33922d7f9acfe2b64600",
|
|
90
|
+
))
|
|
@@ -8,7 +8,7 @@ import os
|
|
|
8
8
|
import re
|
|
9
9
|
import sys
|
|
10
10
|
|
|
11
|
-
from feature_flow import tickets
|
|
11
|
+
from feature_flow import suggest, tickets
|
|
12
12
|
|
|
13
13
|
USAGE = "usage: python3 scripts/flow-status.py <feature> [--next | --counts | --check | --mermaid [plain] | --json]"
|
|
14
14
|
|
|
@@ -48,6 +48,7 @@ def run(argv, out, err):
|
|
|
48
48
|
folder = "%s/%s/%s" % (root, feature, _env("FLOW_TICKETS", "tasks"))
|
|
49
49
|
if not os.path.isdir(folder):
|
|
50
50
|
err.write("no ticket folder: %s\n" % folder)
|
|
51
|
+
err.write(suggest.hint(feature, suggest.features(root)))
|
|
51
52
|
return 2
|
|
52
53
|
paths = tickets.ticket_files(folder)
|
|
53
54
|
if not paths:
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Did-you-mean hints for a mistyped command or feature name."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def closest(word, choices):
|
|
8
|
+
"""The choice nearest to word, or "" when none is close."""
|
|
9
|
+
found = difflib.get_close_matches(word, list(choices), n=1, cutoff=0.6)
|
|
10
|
+
return found[0] if found else ""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def features(root):
|
|
14
|
+
"""The plan folders under root (plans/ or FLOW_DIR)."""
|
|
15
|
+
try:
|
|
16
|
+
return sorted(name for name in os.listdir(str(root)) if os.path.isdir(os.path.join(str(root), name)))
|
|
17
|
+
except OSError:
|
|
18
|
+
return []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def hint(word, choices, template="did you mean: %s?"):
|
|
22
|
+
match = closest(word, choices)
|
|
23
|
+
return (template % match + "\n") if match else ""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: feature-flow-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: A ticket-graph workflow for coding agents: installs the feature-flow skill into a repo
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/hamilton-sky/feature-flow
|
|
@@ -53,7 +53,7 @@ uvx feature-flow-cli install /path/to/your/repo --agent all # or: pipx run fea
|
|
|
53
53
|
|
|
54
54
|
The package puts a `feature-flow` command on your PATH with `install`, `status <feature>` and `view <feature>`. It installs the same files, byte for byte, as `install.py` from a clone.
|
|
55
55
|
|
|
56
|
-
The installer lists what it wrote in `.feature-flow/installed.txt`. Commit those files (`git add --pathspec-from-file=.feature-flow/installed.txt`); until you do, the flow does not count them as uncommitted changes.
|
|
56
|
+
The installer lists what it wrote in `.feature-flow/installed.txt`, with each file's hash in `.feature-flow/installed.sha256`. Commit those files (`git add --pathspec-from-file=.feature-flow/installed.txt`); until you do, the flow does not count them as uncommitted changes. Installing a newer version over an older one updates every file nobody edited since it was installed, and keeps (and names) the ones you changed; `--force` replaces those too.
|
|
57
57
|
|
|
58
58
|
Then, in your repo, in the agent:
|
|
59
59
|
|
|
@@ -206,6 +206,7 @@ A session's context fills up, so the flow moves to a fresh session every few tic
|
|
|
206
206
|
|
|
207
207
|
- **One owner at a time.** `start` gives the session a token, and every later call must carry it, so two sessions can never drive the same feature. `HANDOFF` and `DONE` release it.
|
|
208
208
|
- **Resuming after a closed session.** If a session ended without `HANDOFF` (you closed it, it crashed, it ran out of budget), the feature is still owned by it, and a new session stops and says so. Once you are sure the old session is gone, answer yes when the new session asks, or start it with `FLOW_TAKEOVER=1`. With `auto` the skill never takes over on its own.
|
|
209
|
+
- **Stuck on a ticket.** `python3 scripts/flow.py <feature> reset` (or `feature-flow reset <feature>`) reopens a ticket left `claimed` or `in-progress`, commits that, and clears the conductor's run state, so the next `/feature-flow <feature>` starts clean from the ticket files. `reset <NN>` reopens that one ticket, even a resolved one, to build it again. While a session owns the feature it needs `FLOW_TAKEOVER=1`. A mistyped command or feature name gets a `did you mean` hint.
|
|
209
210
|
- **Relay mode.** `FLOW_RELAY=1` hands off after every build and every review, so each phase gets a session of its own.
|
|
210
211
|
- **Nobody needs to watch.** With `auto` the skill asks nothing, and a Claude Code cloud session keeps working while you are away. You only come back to type the `HANDOFF` line.
|
|
211
212
|
|
|
@@ -21,8 +21,10 @@ feature_flow/gate.py
|
|
|
21
21
|
feature_flow/git.py
|
|
22
22
|
feature_flow/install.py
|
|
23
23
|
feature_flow/prompts.py
|
|
24
|
+
feature_flow/released.py
|
|
24
25
|
feature_flow/state.py
|
|
25
26
|
feature_flow/status.py
|
|
27
|
+
feature_flow/suggest.py
|
|
26
28
|
feature_flow/tickets.py
|
|
27
29
|
feature_flow/view.py
|
|
28
30
|
feature_flow_cli.egg-info/PKG-INFO
|
|
@@ -12,7 +12,7 @@ Follow the repo's documented ticket location if it has one. Otherwise they are i
|
|
|
12
12
|
|
|
13
13
|
## Step 2: Pre-flight
|
|
14
14
|
|
|
15
|
-
Run `git status --porcelain`.
|
|
15
|
+
Run `git status --porcelain`. Files listed in `.feature-flow/installed.txt`, and that list itself, are feature-flow's own install and do not count, and neither does untracked Python bytecode (`__pycache__/`, `*.pyc`).
|
|
16
16
|
|
|
17
17
|
- Clean: continue.
|
|
18
18
|
- Dirty, manual: show the files and offer to commit, stash, or continue anyway.
|
|
@@ -31,7 +31,7 @@ To add tickets to a plan that exists, edit it by hand following the ticket rules
|
|
|
31
31
|
|
|
32
32
|
With a plan present, check these before `start`, and stop at the first that fails:
|
|
33
33
|
|
|
34
|
-
- `git status --porcelain` is empty, apart from untracked Python bytecode (`__pycache__/`, `*.pyc`) and
|
|
34
|
+
- `git status --porcelain` is empty, apart from untracked Python bytecode (`__pycache__/`, `*.pyc`) and files listed in `.feature-flow/installed.txt` and that list itself: those are feature-flow's own install or upgrade, so never stop for them (`next` still stops if one of them was edited). Suggest committing them (`git add --pathspec-from-file=.feature-flow/installed.txt && git commit -m "chore: install feature-flow"`) and carry on.
|
|
35
35
|
- `python3 scripts/flow-status.py <feature> --check` prints `OK`.
|
|
36
36
|
- `ticket-builder.md` and `ticket-reviewer.md` are in `.claude/agents/`, `~/.claude/agents/`, or `$CLAUDE_HOME/agents/` when `CLAUDE_HOME` is set. If not, say to run `uvx feature-flow-cli install .` in this repo (or `python3 install.py <repo>` from a feature-flow clone).
|
|
37
37
|
|
|
@@ -39,6 +39,8 @@ Then run `FLOW_INVOKE=/feature-flow python3 scripts/flow.py <feature> start`. It
|
|
|
39
39
|
|
|
40
40
|
If `start` prints `STOP` naming another owner, another session may still be working this feature. Ask the user whether that session is closed. Only on a clear yes, run `start` once more with `FLOW_TAKEOVER=1`. In `auto` mode, never take over: report and stop.
|
|
41
41
|
|
|
42
|
+
If the user says the flow is stuck on a ticket or asks for a reset, run `reset` (or `reset <NN>` to redo one ticket): it reopens a half-built ticket, commits that, and clears the run, then run `start` again. It needs `FLOW_TAKEOVER=1` while a session owns the feature, on the same clear yes. Never reset on your own.
|
|
43
|
+
|
|
42
44
|
Say what will happen: for each ticket, a builder subagent and then a reviewer subagent. After every few tickets (`FLOW_TICKETS_PER_SESSION`, default 4) you hand off to a new session. Ask for a yes, unless `auto` was given.
|
|
43
45
|
|
|
44
46
|
## The loop
|
|
File without changes
|
|
File without changes
|
{feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/adapters/codex/feature-flow/agents/openai.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{feature_flow_cli-0.1.3 → feature_flow_cli-0.1.5}/feature_flow_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|