swfactory 1.0.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.
- swfactory/__init__.py +8 -0
- swfactory/__main__.py +3 -0
- swfactory/activity.py +307 -0
- swfactory/board.py +494 -0
- swfactory/cli.py +362 -0
- swfactory/config.py +82 -0
- swfactory/design.py +446 -0
- swfactory/git.py +182 -0
- swfactory/init.py +558 -0
- swfactory/memory.py +53 -0
- swfactory/orca.py +280 -0
- swfactory/output.py +110 -0
- swfactory/paths.py +32 -0
- swfactory/py.typed +0 -0
- swfactory/state.py +189 -0
- swfactory/tasks.py +1031 -0
- swfactory/templates/board/board.html +422 -0
- swfactory/templates/codex/factory.rules +16 -0
- swfactory/templates/design/design.md +38 -0
- swfactory/templates/docs/AGENTS.md +65 -0
- swfactory/templates/docs/CLAUDE.md +2 -0
- swfactory/templates/docs/architecture.md +17 -0
- swfactory/templates/docs/conventions.md +16 -0
- swfactory/templates/docs/decisions/README.md +6 -0
- swfactory/templates/docs/decisions/adr.md +16 -0
- swfactory/templates/docs/product.md +13 -0
- swfactory/templates/docs/working-agreements.md +14 -0
- swfactory/templates/roles/conformance_reviewer.md +12 -0
- swfactory/templates/roles/design_critic.md +4 -0
- swfactory/templates/roles/designer.md +4 -0
- swfactory/templates/roles/lead.md +4 -0
- swfactory/templates/roles/planner.md +4 -0
- swfactory/templates/roles/quality_reviewer.md +12 -0
- swfactory/templates/skills/factory-board/SKILL.md +30 -0
- swfactory/templates/skills/factory-design/SKILL.md +55 -0
- swfactory/templates/skills/factory-design/references/approval.md +26 -0
- swfactory/templates/skills/factory-design/references/reviews.md +20 -0
- swfactory/templates/skills/factory-implement/SKILL.md +41 -0
- swfactory/templates/skills/factory-implement/references/lead.md +70 -0
- swfactory/templates/skills/factory-implement/references/memory.md +31 -0
- swfactory/templates/skills/factory-init/SKILL.md +51 -0
- swfactory/templates/skills/factory-status/SKILL.md +54 -0
- swfactory/templates/skills/factory-status/references/recover.md +23 -0
- swfactory-1.0.0.dist-info/METADATA +163 -0
- swfactory-1.0.0.dist-info/RECORD +48 -0
- swfactory-1.0.0.dist-info/WHEEL +4 -0
- swfactory-1.0.0.dist-info/entry_points.txt +3 -0
- swfactory-1.0.0.dist-info/licenses/LICENSE +21 -0
swfactory/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""swfactory: a method and a CLI to build software with coding agents on Orca."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("swfactory")
|
|
7
|
+
except PackageNotFoundError: # running from a source tree that is not installed
|
|
8
|
+
__version__ = "0.0.0"
|
swfactory/__main__.py
ADDED
swfactory/activity.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""What runs in each task workspace: Orca terminals, the agents Orca's hooks report, and the
|
|
2
|
+
subagents of Claude and Codex. Read-only and best effort: a source that cannot be read is
|
|
3
|
+
reported as unavailable, never raised. Orca's hook files and the agents' session folders are
|
|
4
|
+
internal formats of those tools, so every field is read defensively."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from swfactory import orca
|
|
16
|
+
from swfactory.output import FactoryError, mask_secrets
|
|
17
|
+
from swfactory.state import State
|
|
18
|
+
|
|
19
|
+
ACTIVE_SECONDS = 30 # a terminal that wrote less than this ago is "active"
|
|
20
|
+
TEXT_LIMIT = 160
|
|
21
|
+
CODEX_DAYS = 3 # Codex sessions older than this are not scanned
|
|
22
|
+
TERMINAL_LIMIT = "1000"
|
|
23
|
+
INDEX_SECONDS = 2.0 # one scan of the Codex sessions serves every task of one board refresh
|
|
24
|
+
HOOK_DIRS = (".config/orca/agent-hooks", "Library/Application Support/orca/agent-hooks")
|
|
25
|
+
SUBAGENT_EVENTS = ("SubagentStart", "SubagentStop")
|
|
26
|
+
# The agent's state after each of its own hook events (Orca keeps no status for ended sessions).
|
|
27
|
+
TURN_STATES = {"SessionStart": "idle", "UserPromptSubmit": "working", "Stop": "done", "SessionEnd": "ended"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def iso_ms(value: Any) -> str | None:
|
|
31
|
+
"""A millisecond epoch (Orca's timestamps) as the state's ISO format."""
|
|
32
|
+
if not isinstance(value, int | float) or value <= 0:
|
|
33
|
+
return None
|
|
34
|
+
return datetime.fromtimestamp(value / 1000, UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _iso_s(value: float) -> str:
|
|
38
|
+
return datetime.fromtimestamp(value, UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _text(value: Any) -> str | None:
|
|
42
|
+
if not isinstance(value, str) or not value.strip():
|
|
43
|
+
return None
|
|
44
|
+
text = " ".join(mask_secrets(value).split())
|
|
45
|
+
return text if len(text) <= TEXT_LIMIT else text[: TEXT_LIMIT - 1] + "…"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --- Orca: workspaces and terminals (documented CLI) ---------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def orca_view() -> dict[str, Any]:
|
|
52
|
+
"""Every Orca workspace and live terminal, in two calls. `ok` is False when Orca cannot answer."""
|
|
53
|
+
view: dict[str, Any] = {"ok": True, "error": None, "worktrees": {}, "terminals": [], "complete": True}
|
|
54
|
+
try:
|
|
55
|
+
view["worktrees"] = {w["worktreeId"]: w for w in orca.run(["worktree", "ps"])["worktrees"]}
|
|
56
|
+
listed = orca.run(["terminal", "list", "--limit", TERMINAL_LIMIT])
|
|
57
|
+
view["terminals"] = list(listed["terminals"])
|
|
58
|
+
view["complete"] = not listed.get("truncated")
|
|
59
|
+
except (FactoryError, KeyError, TypeError) as exc:
|
|
60
|
+
view.update(ok=False, error=getattr(exc, "message", str(exc)), worktrees={}, terminals=[])
|
|
61
|
+
return view
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def liveness(record: State, view: dict[str, Any]) -> str:
|
|
65
|
+
"""Like `orca.session_liveness`, from the terminal list instead of one call per task."""
|
|
66
|
+
handle = record["attempts"][-1].get("terminal") if record["attempts"] else None
|
|
67
|
+
if not view["ok"] or not handle:
|
|
68
|
+
return "unknown"
|
|
69
|
+
terminal = next((t for t in view["terminals"] if t.get("handle") == handle), None)
|
|
70
|
+
if terminal is None and not view.get("complete", True):
|
|
71
|
+
return "unknown" # Orca cut the list short: absent is not proof of closed
|
|
72
|
+
return "alive" if terminal and terminal.get("connected") else "finished"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def workspace_missing(record: State, view: dict[str, Any]) -> bool | None:
|
|
76
|
+
if not view["ok"]:
|
|
77
|
+
return None
|
|
78
|
+
return record["workspace"]["orca_id"] not in view["worktrees"]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --- Orca's agent hooks (internal files) ----------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
_spool_cache: dict[Path, tuple[float, int, tuple[dict[str, Any] | None, list[dict[str, Any]]]]] = {}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def orca_hooks(home: Path) -> dict[str, Any]:
|
|
87
|
+
"""The agents Orca's hooks saw, by terminal pane (`leafId`), and every subagent start/stop."""
|
|
88
|
+
folder = next((home / d for d in HOOK_DIRS if (home / d).is_dir()), None)
|
|
89
|
+
result: dict[str, Any] = {"ok": folder is not None, "panes": {}, "subagents": []}
|
|
90
|
+
if folder is None:
|
|
91
|
+
return result
|
|
92
|
+
try:
|
|
93
|
+
entries = json.loads((folder / "last-status.json").read_text(encoding="utf-8")).get("entries", {})
|
|
94
|
+
except (OSError, ValueError, AttributeError):
|
|
95
|
+
entries = {}
|
|
96
|
+
for key, entry in entries.items() if isinstance(entries, dict) else []:
|
|
97
|
+
if not isinstance(entry, dict):
|
|
98
|
+
continue
|
|
99
|
+
payload: dict[str, Any] = entry["payload"] if isinstance(entry.get("payload"), dict) else {}
|
|
100
|
+
result["panes"][str(key).rsplit(":", 1)[-1]] = {
|
|
101
|
+
"provider": entry.get("source"),
|
|
102
|
+
"state": payload.get("state"),
|
|
103
|
+
"prompt": _text(payload.get("prompt")),
|
|
104
|
+
"at": iso_ms(entry.get("receivedAt")),
|
|
105
|
+
}
|
|
106
|
+
spool = sorted((folder / "spool").glob("pane-*.jsonl"))
|
|
107
|
+
for gone in set(_spool_cache) - set(spool):
|
|
108
|
+
del _spool_cache[gone]
|
|
109
|
+
for path in spool:
|
|
110
|
+
pane, subagents = _spool(path)
|
|
111
|
+
result["subagents"] += subagents
|
|
112
|
+
leaf = path.stem.removeprefix("pane-")
|
|
113
|
+
if pane and leaf not in result["panes"]:
|
|
114
|
+
result["panes"][leaf] = pane
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _spool(path: Path) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
|
119
|
+
"""One pane's spool file: its agent's last state and its subagent events. Cached while the
|
|
120
|
+
file does not change."""
|
|
121
|
+
try:
|
|
122
|
+
stat = path.stat()
|
|
123
|
+
except OSError:
|
|
124
|
+
return None, []
|
|
125
|
+
cached = _spool_cache.get(path)
|
|
126
|
+
if cached and cached[:2] == (stat.st_mtime, stat.st_size):
|
|
127
|
+
return cached[2]
|
|
128
|
+
pane: dict[str, Any] | None = None
|
|
129
|
+
subagents = []
|
|
130
|
+
try:
|
|
131
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
132
|
+
except OSError:
|
|
133
|
+
lines = []
|
|
134
|
+
for line in lines:
|
|
135
|
+
try:
|
|
136
|
+
event = json.loads(line)
|
|
137
|
+
payload = event["payload"]
|
|
138
|
+
name = payload["hook_event_name"]
|
|
139
|
+
except (ValueError, KeyError, TypeError):
|
|
140
|
+
continue
|
|
141
|
+
if name in TURN_STATES:
|
|
142
|
+
pane = {"provider": event.get("source"), "state": TURN_STATES[name], "at": iso_ms(event.get("receivedAt")),
|
|
143
|
+
"prompt": _text(payload.get("prompt")) or (pane or {}).get("prompt")}
|
|
144
|
+
elif name in SUBAGENT_EVENTS and payload.get("agent_type"): # untyped ones are the client's own
|
|
145
|
+
subagents.append({"event": name, "provider": event.get("source"), "cwd": payload.get("cwd"),
|
|
146
|
+
"id": payload.get("agent_id"), "type": payload.get("agent_type"),
|
|
147
|
+
"at": iso_ms(event.get("receivedAt")),
|
|
148
|
+
"tasks": payload.get("background_tasks"),
|
|
149
|
+
"last": _text(payload.get("last_assistant_message"))})
|
|
150
|
+
_spool_cache[path] = (stat.st_mtime, stat.st_size, (pane, subagents))
|
|
151
|
+
return pane, subagents
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# --- Agents' own session folders (internal formats) -----------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_claude_cache: dict[Path, tuple[float, float, dict[str, Any] | None]] = {}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def claude_subagents(home: Path, workspace: Path) -> list[dict[str, Any]]:
|
|
161
|
+
"""Subagents Claude Code recorded for sessions run in `workspace`."""
|
|
162
|
+
project = home / ".claude" / "projects" / re.sub(r"[^A-Za-z0-9]", "-", str(workspace))
|
|
163
|
+
found = []
|
|
164
|
+
for meta_path in sorted(project.glob("*/subagents/*.meta.json")):
|
|
165
|
+
sub = _claude_subagent(meta_path)
|
|
166
|
+
if sub:
|
|
167
|
+
found.append(sub)
|
|
168
|
+
return found
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _claude_subagent(meta_path: Path) -> dict[str, Any] | None:
|
|
172
|
+
"""One subagent from its `.meta.json` and transcript, cached while neither changes."""
|
|
173
|
+
transcript = meta_path.with_name(meta_path.name.removesuffix(".meta.json") + ".jsonl")
|
|
174
|
+
try:
|
|
175
|
+
meta_time = meta_path.stat().st_mtime
|
|
176
|
+
last = transcript.stat().st_mtime if transcript.exists() else meta_time
|
|
177
|
+
except OSError:
|
|
178
|
+
return None
|
|
179
|
+
cached = _claude_cache.get(meta_path)
|
|
180
|
+
if cached and cached[:2] == (meta_time, last):
|
|
181
|
+
return cached[2]
|
|
182
|
+
try:
|
|
183
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
184
|
+
except (OSError, ValueError):
|
|
185
|
+
meta = None
|
|
186
|
+
sub = None
|
|
187
|
+
if isinstance(meta, dict):
|
|
188
|
+
sub = {"id": meta_path.name.removesuffix(".meta.json").removeprefix("agent-"),
|
|
189
|
+
"provider": "claude", "type": meta.get("agentType"),
|
|
190
|
+
"label": _text(meta.get("description")), "running": not meta.get("finished", False),
|
|
191
|
+
"started": _first_timestamp(transcript), "last_activity": _iso_s(last)}
|
|
192
|
+
_claude_cache[meta_path] = (meta_time, last, sub)
|
|
193
|
+
return sub
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _first_timestamp(path: Path) -> str | None:
|
|
197
|
+
try:
|
|
198
|
+
with path.open(encoding="utf-8") as handle:
|
|
199
|
+
for line in handle:
|
|
200
|
+
stamp = json.loads(line).get("timestamp")
|
|
201
|
+
if isinstance(stamp, str):
|
|
202
|
+
return stamp[:19] + "Z"
|
|
203
|
+
except (OSError, ValueError, AttributeError):
|
|
204
|
+
pass
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
_codex_index: dict[Path, tuple[float, dict[str, list[dict[str, Any]]]]] = {}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def codex_sessions(home: Path, workspace: Path) -> list[dict[str, Any]]:
|
|
212
|
+
"""Recent Codex sessions run in `workspace`; those whose `source` names a subagent count as
|
|
213
|
+
subagents. Not verified against a real Codex: unreadable or unknown files are skipped."""
|
|
214
|
+
root = home / ".codex" / "sessions"
|
|
215
|
+
cached = _codex_index.get(root)
|
|
216
|
+
if cached is None or time.monotonic() - cached[0] > INDEX_SECONDS:
|
|
217
|
+
cached = (time.monotonic(), _scan_codex(root))
|
|
218
|
+
_codex_index[root] = cached
|
|
219
|
+
return cached[1].get(str(workspace), [])
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _scan_codex(root: Path) -> dict[str, list[dict[str, Any]]]:
|
|
223
|
+
if not root.is_dir():
|
|
224
|
+
return {}
|
|
225
|
+
since = time.time() - CODEX_DAYS * 86400
|
|
226
|
+
by_cwd: dict[str, list[dict[str, Any]]] = {}
|
|
227
|
+
for path in sorted(root.glob("*/*/*/rollout-*.jsonl")):
|
|
228
|
+
try:
|
|
229
|
+
modified = path.stat().st_mtime
|
|
230
|
+
if modified < since:
|
|
231
|
+
continue
|
|
232
|
+
with path.open(encoding="utf-8") as handle:
|
|
233
|
+
meta = json.loads(handle.readline()).get("payload", {})
|
|
234
|
+
except (OSError, ValueError, AttributeError):
|
|
235
|
+
continue
|
|
236
|
+
if not isinstance(meta, dict) or not isinstance(meta.get("cwd"), str):
|
|
237
|
+
continue
|
|
238
|
+
by_cwd.setdefault(meta["cwd"], []).append({
|
|
239
|
+
"id": meta.get("id") or path.stem, "provider": "codex",
|
|
240
|
+
"subagent": "subagent" in json.dumps(meta.get("source", "")).lower(),
|
|
241
|
+
"label": _text(meta.get("agent_nickname") or meta.get("agent_role")),
|
|
242
|
+
"started": meta.get("timestamp"), "last_activity": _iso_s(modified),
|
|
243
|
+
"running": time.time() - modified < 2 * ACTIVE_SECONDS})
|
|
244
|
+
return by_cwd
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# --- Everything that runs in one workspace --------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def runs(workspace: Path, record: State, view: dict[str, Any], hooks: dict[str, Any], home: Path,
|
|
251
|
+
now: float | None = None) -> list[dict[str, Any]]:
|
|
252
|
+
"""Terminals (with the agent in each), then subagents, for one task workspace."""
|
|
253
|
+
now = time.time() if now is None else now
|
|
254
|
+
lead = record["attempts"][-1].get("terminal") if record["attempts"] else None
|
|
255
|
+
found: list[dict[str, Any]] = []
|
|
256
|
+
for terminal in view["terminals"]:
|
|
257
|
+
if terminal.get("worktreePath") != str(workspace):
|
|
258
|
+
continue
|
|
259
|
+
pane = hooks["panes"].get(str(terminal.get("leafId")))
|
|
260
|
+
last = terminal.get("lastOutputAt")
|
|
261
|
+
recent = isinstance(last, int | float) and now - last / 1000 < ACTIVE_SECONDS
|
|
262
|
+
if not terminal.get("connected"):
|
|
263
|
+
state = "closed"
|
|
264
|
+
elif pane and pane["state"]:
|
|
265
|
+
state = str(pane["state"])
|
|
266
|
+
else:
|
|
267
|
+
state = "active" if recent else "idle"
|
|
268
|
+
found.append({"kind": "agent" if pane else "terminal", "provider": pane["provider"] if pane else None,
|
|
269
|
+
"role": "lead" if terminal.get("handle") == lead else None,
|
|
270
|
+
"label": _text(terminal.get("title")), "state": state, "active": bool(recent),
|
|
271
|
+
"last_activity": iso_ms(last), "detail": (pane or {}).get("prompt")})
|
|
272
|
+
found += _subagents(workspace, hooks, home)
|
|
273
|
+
return found
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _subagents(workspace: Path, hooks: dict[str, Any], home: Path) -> list[dict[str, Any]]:
|
|
277
|
+
merged: dict[str, dict[str, Any]] = {}
|
|
278
|
+
for event in hooks["subagents"]:
|
|
279
|
+
if event["cwd"] != str(workspace) or not event["id"]:
|
|
280
|
+
continue
|
|
281
|
+
item = merged.setdefault(event["id"], {"kind": "subagent", "provider": event["provider"],
|
|
282
|
+
"type": event["type"], "label": None, "state": "running",
|
|
283
|
+
"started": None, "last_activity": None, "detail": None})
|
|
284
|
+
if event["event"] == "SubagentStart":
|
|
285
|
+
item["started"] = event["at"]
|
|
286
|
+
else:
|
|
287
|
+
item.update(state="done", last_activity=event["at"], detail=event["last"])
|
|
288
|
+
for task in event["tasks"] if isinstance(event["tasks"], list) else []:
|
|
289
|
+
if isinstance(task, dict) and task.get("id") == event["id"]:
|
|
290
|
+
item["label"] = item["label"] or _text(task.get("description"))
|
|
291
|
+
for sub in claude_subagents(home, workspace):
|
|
292
|
+
item = merged.setdefault(sub["id"], {"kind": "subagent", "provider": "claude", "type": sub["type"],
|
|
293
|
+
"label": None, "state": "running", "started": None,
|
|
294
|
+
"last_activity": None, "detail": None})
|
|
295
|
+
item["label"] = sub["label"] or item["label"]
|
|
296
|
+
item["started"] = item["started"] or sub["started"]
|
|
297
|
+
item["last_activity"] = item["last_activity"] or sub["last_activity"]
|
|
298
|
+
if not sub["running"]:
|
|
299
|
+
item["state"] = "done"
|
|
300
|
+
for session in codex_sessions(home, workspace):
|
|
301
|
+
if session["subagent"]:
|
|
302
|
+
merged.setdefault(session["id"], {"kind": "subagent", "provider": "codex", "type": None,
|
|
303
|
+
"label": session["label"],
|
|
304
|
+
"state": "running" if session["running"] else "done",
|
|
305
|
+
"started": session["started"],
|
|
306
|
+
"last_activity": session["last_activity"], "detail": None})
|
|
307
|
+
return sorted(merged.values(), key=lambda s: s["started"] or "")
|