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.
Files changed (48) hide show
  1. swfactory/__init__.py +8 -0
  2. swfactory/__main__.py +3 -0
  3. swfactory/activity.py +307 -0
  4. swfactory/board.py +494 -0
  5. swfactory/cli.py +362 -0
  6. swfactory/config.py +82 -0
  7. swfactory/design.py +446 -0
  8. swfactory/git.py +182 -0
  9. swfactory/init.py +558 -0
  10. swfactory/memory.py +53 -0
  11. swfactory/orca.py +280 -0
  12. swfactory/output.py +110 -0
  13. swfactory/paths.py +32 -0
  14. swfactory/py.typed +0 -0
  15. swfactory/state.py +189 -0
  16. swfactory/tasks.py +1031 -0
  17. swfactory/templates/board/board.html +422 -0
  18. swfactory/templates/codex/factory.rules +16 -0
  19. swfactory/templates/design/design.md +38 -0
  20. swfactory/templates/docs/AGENTS.md +65 -0
  21. swfactory/templates/docs/CLAUDE.md +2 -0
  22. swfactory/templates/docs/architecture.md +17 -0
  23. swfactory/templates/docs/conventions.md +16 -0
  24. swfactory/templates/docs/decisions/README.md +6 -0
  25. swfactory/templates/docs/decisions/adr.md +16 -0
  26. swfactory/templates/docs/product.md +13 -0
  27. swfactory/templates/docs/working-agreements.md +14 -0
  28. swfactory/templates/roles/conformance_reviewer.md +12 -0
  29. swfactory/templates/roles/design_critic.md +4 -0
  30. swfactory/templates/roles/designer.md +4 -0
  31. swfactory/templates/roles/lead.md +4 -0
  32. swfactory/templates/roles/planner.md +4 -0
  33. swfactory/templates/roles/quality_reviewer.md +12 -0
  34. swfactory/templates/skills/factory-board/SKILL.md +30 -0
  35. swfactory/templates/skills/factory-design/SKILL.md +55 -0
  36. swfactory/templates/skills/factory-design/references/approval.md +26 -0
  37. swfactory/templates/skills/factory-design/references/reviews.md +20 -0
  38. swfactory/templates/skills/factory-implement/SKILL.md +41 -0
  39. swfactory/templates/skills/factory-implement/references/lead.md +70 -0
  40. swfactory/templates/skills/factory-implement/references/memory.md +31 -0
  41. swfactory/templates/skills/factory-init/SKILL.md +51 -0
  42. swfactory/templates/skills/factory-status/SKILL.md +54 -0
  43. swfactory/templates/skills/factory-status/references/recover.md +23 -0
  44. swfactory-1.0.0.dist-info/METADATA +163 -0
  45. swfactory-1.0.0.dist-info/RECORD +48 -0
  46. swfactory-1.0.0.dist-info/WHEEL +4 -0
  47. swfactory-1.0.0.dist-info/entry_points.txt +3 -0
  48. 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
@@ -0,0 +1,3 @@
1
+ from swfactory.cli import run
2
+
3
+ run()
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 "")