agent-pmo 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 (107) hide show
  1. agent_pmo/__init__.py +7 -0
  2. agent_pmo/__main__.py +22 -0
  3. agent_pmo/adherence.py +455 -0
  4. agent_pmo/adopt.py +693 -0
  5. agent_pmo/automation.py +391 -0
  6. agent_pmo/backlog.py +251 -0
  7. agent_pmo/bootstrap.py +322 -0
  8. agent_pmo/changelog.py +340 -0
  9. agent_pmo/changes.py +156 -0
  10. agent_pmo/checks.py +115 -0
  11. agent_pmo/cli.py +1483 -0
  12. agent_pmo/coderabbit.py +258 -0
  13. agent_pmo/config.py +170 -0
  14. agent_pmo/container.py +95 -0
  15. agent_pmo/coord.py +277 -0
  16. agent_pmo/decisions.py +305 -0
  17. agent_pmo/deploy.py +925 -0
  18. agent_pmo/discuss.py +562 -0
  19. agent_pmo/gitx.py +48 -0
  20. agent_pmo/guide.py +184 -0
  21. agent_pmo/hosting.py +118 -0
  22. agent_pmo/issues.py +133 -0
  23. agent_pmo/kit/CHANGELOG.md +23 -0
  24. agent_pmo/kit/WORKFLOW.md +159 -0
  25. agent_pmo/kit/docs/README.md +14 -0
  26. agent_pmo/kit/docs/automation.md +60 -0
  27. agent_pmo/kit/docs/commands.md +400 -0
  28. agent_pmo/kit/docs/concepts.md +86 -0
  29. agent_pmo/kit/docs/configuration.md +88 -0
  30. agent_pmo/kit/docs/development.md +97 -0
  31. agent_pmo/kit/docs/getting-started.md +65 -0
  32. agent_pmo/kit/profiles/generic.md +10 -0
  33. agent_pmo/kit/profiles/generic.toml +51 -0
  34. agent_pmo/kit/profiles/go.md +8 -0
  35. agent_pmo/kit/profiles/go.toml +57 -0
  36. agent_pmo/kit/profiles/java.md +8 -0
  37. agent_pmo/kit/profiles/java.toml +56 -0
  38. agent_pmo/kit/profiles/python.md +46 -0
  39. agent_pmo/kit/profiles/python.toml +61 -0
  40. agent_pmo/kit/profiles/rust.md +8 -0
  41. agent_pmo/kit/profiles/rust.toml +57 -0
  42. agent_pmo/kit/profiles/typescript.md +10 -0
  43. agent_pmo/kit/profiles/typescript.toml +60 -0
  44. agent_pmo/kit/templates/agents/AGENTS.md +9 -0
  45. agent_pmo/kit/templates/agents/CLAUDE.md +9 -0
  46. agent_pmo/kit/templates/agents/GEMINI.md +9 -0
  47. agent_pmo/kit/templates/agents/copilot-instructions.md +5 -0
  48. agent_pmo/kit/templates/agents/cursor-workflow.mdc +8 -0
  49. agent_pmo/kit/templates/architecture.md +23 -0
  50. agent_pmo/kit/templates/architecture_changelog.md +8 -0
  51. agent_pmo/kit/templates/assumptions.md +10 -0
  52. agent_pmo/kit/templates/backlog_index.md +6 -0
  53. agent_pmo/kit/templates/change_request.md +46 -0
  54. agent_pmo/kit/templates/changelog.md +8 -0
  55. agent_pmo/kit/templates/changes_index.md +7 -0
  56. agent_pmo/kit/templates/decisions_index.md +9 -0
  57. agent_pmo/kit/templates/deploy_changelog.md +8 -0
  58. agent_pmo/kit/templates/deploy_deployments.md +6 -0
  59. agent_pmo/kit/templates/docs_readme.md +12 -0
  60. agent_pmo/kit/templates/guide_agent.md +54 -0
  61. agent_pmo/kit/templates/guide_changelog.md +10 -0
  62. agent_pmo/kit/templates/guide_coding_style.md +7 -0
  63. agent_pmo/kit/templates/guide_developer.md +114 -0
  64. agent_pmo/kit/templates/guide_project_manager.md +104 -0
  65. agent_pmo/kit/templates/module_readme.md +19 -0
  66. agent_pmo/kit/templates/security_index.md +6 -0
  67. agent_pmo/kit/templates/spec.md +77 -0
  68. agent_pmo/kit/templates/spec_index.md +28 -0
  69. agent_pmo/kit/workflows/adopt.md +15 -0
  70. agent_pmo/kit/workflows/change-request.md +17 -0
  71. agent_pmo/kit/workflows/deploy.md +28 -0
  72. agent_pmo/kit/workflows/discuss.md +21 -0
  73. agent_pmo/kit/workflows/fix.md +11 -0
  74. agent_pmo/kit/workflows/implement.md +21 -0
  75. agent_pmo/kit/workflows/merge.md +9 -0
  76. agent_pmo/kit/workflows/oneoff.md +16 -0
  77. agent_pmo/kit/workflows/parallel.md +12 -0
  78. agent_pmo/kit/workflows/planning.md +27 -0
  79. agent_pmo/kit/workflows/release.md +12 -0
  80. agent_pmo/kit/workflows/revert.md +12 -0
  81. agent_pmo/kit/workflows/review.md +31 -0
  82. agent_pmo/kit/workflows/scheduled.md +11 -0
  83. agent_pmo/kit/workflows/security.md +11 -0
  84. agent_pmo/kit/workflows/setup.md +70 -0
  85. agent_pmo/kit/workflows/test.md +15 -0
  86. agent_pmo/kit/workflows/triage.md +18 -0
  87. agent_pmo/kit.py +260 -0
  88. agent_pmo/landing.py +763 -0
  89. agent_pmo/md.py +96 -0
  90. agent_pmo/oneoff.py +329 -0
  91. agent_pmo/provider.py +562 -0
  92. agent_pmo/registry.py +689 -0
  93. agent_pmo/release.py +108 -0
  94. agent_pmo/reports.py +462 -0
  95. agent_pmo/review.py +404 -0
  96. agent_pmo/security.py +402 -0
  97. agent_pmo/specedit.py +147 -0
  98. agent_pmo/style.py +775 -0
  99. agent_pmo/templates.py +13 -0
  100. agent_pmo/trace.py +237 -0
  101. agent_pmo/traceability.py +222 -0
  102. agent_pmo/triage.py +336 -0
  103. agent_pmo-0.1.0.dist-info/METADATA +73 -0
  104. agent_pmo-0.1.0.dist-info/RECORD +107 -0
  105. agent_pmo-0.1.0.dist-info/WHEEL +4 -0
  106. agent_pmo-0.1.0.dist-info/entry_points.txt +2 -0
  107. agent_pmo-0.1.0.dist-info/licenses/LICENSE +21 -0
agent_pmo/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """agent-pmo: a project management office for AI coding agents (specs, plans, decisions, tests, reviews, releases).
2
+
3
+ Language-neutral and standard-library only (Python >= 3.11). Everything project-specific lives in the project's
4
+ pm.toml; the kit's documentation is in docs/ (commands.md, configuration.md).
5
+ """
6
+
7
+ __version__ = "0.1.0"
agent_pmo/__main__.py ADDED
@@ -0,0 +1,22 @@
1
+ """`agent-pmo` and `python -m agent_pmo`: the command-line entry point (Python >= 3.11, standard library only)."""
2
+
3
+ import sys
4
+
5
+
6
+ def run() -> int:
7
+ if sys.version_info < (3, 11):
8
+ sys.exit("agent-pmo needs Python 3.11 or newer (uses tomllib)")
9
+ if not sys.flags.utf8_mode:
10
+ import locale
11
+
12
+ if locale.getencoding().lower().replace("-", "") != "utf8": # e.g. Windows code pages: docs are UTF-8 everywhere
13
+ import subprocess
14
+
15
+ return subprocess.call([sys.executable, "-X", "utf8", "-m", "agent_pmo", *sys.argv[1:]])
16
+ from .cli import main
17
+
18
+ return main()
19
+
20
+
21
+ if __name__ == "__main__":
22
+ sys.exit(run())
agent_pmo/adherence.py ADDED
@@ -0,0 +1,455 @@
1
+ """Workflow adherence: did an agent follow every step of a workflow? Checked from the evidence the steps leave behind.
2
+
3
+ Each check lists the workflow's steps (numbered as in .pm/workflows/<name>.md) with ok, missing or n/a and why. A
4
+ missing step means it was skipped: do it. `report` runs the checks for every unit, backlog fix and change request
5
+ and summarises them; the pre-push hook runs the check matching a pushed unit branch's status.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from . import gitx, md
17
+ from .config import Config
18
+
19
+ WORKFLOWS = ("setup", "implement", "test", "review", "change-request", "security", "fix", "oneoff", "adopt", "triage",
20
+ "discuss", "deploy") # fmt: skip
21
+
22
+
23
+ @dataclass
24
+ class Step:
25
+ n: str
26
+ name: str
27
+ status: str # ok | missing | n/a
28
+ detail: str = ""
29
+
30
+
31
+ @dataclass
32
+ class Result:
33
+ workflow: str
34
+ subject: str
35
+ steps: list[Step] = field(default_factory=list)
36
+
37
+ def add(self, n: str, name: str, ok: bool | None, detail: str = "") -> None:
38
+ self.steps.append(Step(n, name, "n/a" if ok is None else ("ok" if ok else "missing"), detail))
39
+
40
+ @property
41
+ def missing(self) -> list[Step]:
42
+ return [s for s in self.steps if s.status == "missing"]
43
+
44
+ @property
45
+ def passed(self) -> bool:
46
+ return not self.missing
47
+
48
+ def text(self) -> str:
49
+ counted = [s for s in self.steps if s.status != "n/a"]
50
+ lines = [f"{self.workflow} {self.subject}: {len(counted) - len(self.missing)}/{len(counted)} steps"
51
+ + ("" if self.passed else f" — {len(self.missing)} missing (workflows/{self.workflow}.md)")]
52
+ mark = {"ok": "ok ", "missing": "MISSING", "n/a": "n/a"}
53
+ lines += [f" {mark[s.status]:<7} step {s.n} {s.name}" + (f": {s.detail}" if s.detail else "") for s in self.steps]
54
+ return "\n".join(lines)
55
+
56
+
57
+ # ---------------------------------------------------------------- helpers
58
+
59
+
60
+ def _unit_commits(cfg: Config, unit: str, trailer: str = "Unit") -> list[dict[str, Any]]:
61
+ from . import trace
62
+
63
+ return [c for c in trace.commits(cfg) if c["trailers"].get(trailer) == unit]
64
+
65
+
66
+ def _module(cfg: Config, name: str) -> Any:
67
+ from . import registry
68
+
69
+ mods = registry.load(cfg)
70
+ if name not in mods:
71
+ raise ValueError(f"unknown module {name!r}")
72
+ return mods[name]
73
+
74
+
75
+ def _relevant(problems: list[str], *needles: str) -> list[str]:
76
+ return [p for p in problems if any(n and n in p for n in needles)]
77
+
78
+
79
+ # ---------------------------------------------------------------- unit workflows
80
+
81
+
82
+ def implement(cfg: Config, module: str, k: int, *, prepush: bool = False) -> Result:
83
+ from . import checks, coord, registry
84
+
85
+ m = _module(cfg, module)
86
+ inc = m.inc(k)
87
+ if inc is None:
88
+ raise ValueError(f"{module} has no increment {k}")
89
+ r = Result("implement", f"{module} inc {k}")
90
+ unit = f"{module} inc {k}"
91
+ commits = _unit_commits(cfg, unit)
92
+ held = {c.module: c for c in coord.claims(cfg, include_expired=True)} if coord.mode(cfg) != "off" else {}
93
+ claimed = module in held or any("Agent" in c["trailers"] for c in commits)
94
+ r.add("1", "claim the module (next implement --claim)", None if coord.mode(cfg) == "off" else claimed,
95
+ held[module].agent if module in held else ("" if claimed else "no claim and no Agent trailer")) # fmt: skip
96
+ branch = gitx.current_branch(cfg.root)
97
+ pattern = re.compile(rf"^{re.escape(cfg.prefix.lower())}-\d{{4}}-{re.escape(module)}-inc{k}$")
98
+ on_branch = branch not in ("", "HEAD", cfg.default_branch, cfg.get("git.dev", "dev")) and f"inc{k}" in branch
99
+ r.add("3", "work on the unit branch <prefix>-NNNN-<module>-inc<k>", bool(pattern.match(branch)) if on_branch else None,
100
+ branch if on_branch else "") # fmt: skip
101
+ files = re.findall(r"^- `([^`]+)`", registry.increment_body(cfg, m, k).split("**Files**", 1)[-1].split("**Tests**", 1)[0],
102
+ re.M) if "**Files**" in registry.increment_body(cfg, m, k) else [] # fmt: skip
103
+ absent = [f for f in files if not (cfg.root / f).exists() and "(planned)" not in f]
104
+ r.add("4", "create the increment's files", not absent if inc.status != "planned" else None,
105
+ ("missing: " + ", ".join(absent)) if absent else "") # fmt: skip
106
+ r.add("6", "status coded (or later) with every task ticked", inc.status != "planned" and not inc.open_tasks,
107
+ f"status {inc.status}, {inc.open_tasks} task(s) open") # fmt: skip
108
+ ids = {c["trailers"].get("Change-Id") for c in commits}
109
+ log = (m.folder / "changelog.md").read_text() if (m.folder / "changelog.md").exists() else ""
110
+ r.add("6", "a changelog row for the unit's change", any(i and f"| {i} |" in log for i in ids),
111
+ "" if ids else "no commit carries this unit's trailers yet") # fmt: skip
112
+ probs = _relevant(checks.run(cfg), m.folder.relative_to(cfg.root).as_posix(), m.dir.relative_to(cfg.root).as_posix(),
113
+ f" {module} inc {k}") # fmt: skip
114
+ r.add("7", "agent-pmo check clean for this module", not probs, "; ".join(probs[:3]))
115
+ feature = [c for c in commits if c["trailers"].get("Type") == "feature"]
116
+ r.add("8", "commit with trailers (Change-Id, Unit, Type feature, Implemented-By)",
117
+ any("Implemented-By" in c["trailers"] and "Change-Id" in c["trailers"] for c in feature),
118
+ "" if feature else "no commit with Unit + Type: feature") # fmt: skip
119
+ return r
120
+
121
+
122
+ def test(cfg: Config, module: str, k: int, *, prepush: bool = False) -> Result:
123
+ from . import reports
124
+
125
+ m = _module(cfg, module)
126
+ inc = m.inc(k)
127
+ r = Result("test", f"{module} inc {k}")
128
+ folder = m.folder / "test"
129
+ plans = reports.for_unit(reports.numbered(folder, "test_plan"), module, k)
130
+ reps = reports.for_unit(reports.numbered(folder, "test_report"), module, k)
131
+ r.add("2", "test plan, filled in", bool(plans) and md.PLACEHOLDER not in plans[-1].read_text(),
132
+ "" if plans else "no test plan") # fmt: skip
133
+ latest = reps[-1].read_text() if reps else ""
134
+ r.add("6", "PASS test report, filled in (coverage of the plan, bugs found)",
135
+ "**Result:** PASS" in latest and md.PLACEHOLDER not in latest, "" if reps else "no test report") # fmt: skip
136
+ r.add("7", "status tested (or reviewed)", inc.status in ("tested", "reviewed"), f"status {inc.status}")
137
+ log = (m.folder / "changelog.md").read_text() if (m.folder / "changelog.md").exists() else ""
138
+ r.add("7", "the changelog links the test report", bool(reps) and f"test/{reps[-1].name}" in log)
139
+ requests = reports.for_unit(reports.numbered(m.folder / "review", "request"), module, k)
140
+ r.add("8", "review requested (testing ends by triggering the review)", bool(requests))
141
+ commits = _unit_commits(cfg, f"{module} inc {k}")
142
+ r.add("9", "commit with Type: test", any(c["trailers"].get("Type") == "test" for c in commits))
143
+ return r
144
+
145
+
146
+ def review(cfg: Config, module: str, k: int, *, prepush: bool = False) -> Result:
147
+ from . import review as rv
148
+ from . import security
149
+
150
+ m = _module(cfg, module)
151
+ inc = m.inc(k)
152
+ r = Result("review", f"{module} inc {k}")
153
+ sec = security.latest_module_report(m)
154
+ sec_text = sec.read_text() if sec else ""
155
+ r.add("2", "module security report: PASS and assessed", bool(sec) and "**Result:** PASS" in sec_text
156
+ and md.PLACEHOLDER not in sec_text, "" if sec else "no module security report") # fmt: skip
157
+ from . import reports
158
+
159
+ r.add("3", "review request filed", bool(reports.for_unit(reports.numbered(m.folder / "review", "request"), module, k)))
160
+ probs = rv.review_problems(cfg, m, inc) if rv._reviews(m, k) else ["no code review"]
161
+ independent = [p for p in probs if "authored" in p or "reviewer model" in p]
162
+ r.add("5", "independent review by a different model (findings with backlog items)",
163
+ bool(rv._reviews(m, k)) and not independent and not [p for p in probs if "BL-" in p or "finding" in p],
164
+ "; ".join(probs[:2])) # fmt: skip
165
+ from . import coderabbit
166
+
167
+ if coderabbit.enabled(cfg):
168
+ cr = coderabbit.unit_problems(cfg, m, k)
169
+ r.add("5", "CodeRabbit review fetched and every finding triaged", not cr, "; ".join(cr))
170
+ r.add("8", "approved as the rules require", bool(rv._reviews(m, k)) and not [p for p in probs if "approv" in p],
171
+ "; ".join(p for p in probs if "approv" in p)[:200]) # fmt: skip
172
+ r.add("9", "status reviewed and the changelog links the review", inc.status == "reviewed" and not
173
+ [p for p in probs if "changelog" in p], f"status {inc.status}") # fmt: skip
174
+ commits = _unit_commits(cfg, f"{module} inc {k}")
175
+ r.add("9", "commit with Type: review and Reviewed-By", any(c["trailers"].get("Type") == "review" and
176
+ "Reviewed-By" in c["trailers"] for c in commits)) # fmt: skip
177
+ if prepush or cfg.strategy == "local":
178
+ r.add("10", "landed (merged into the integration branch)", None if prepush else _landed(cfg, commits))
179
+ else:
180
+ r.add("10", "landed (agent PR merged into dev, review PR opened)", _landed(cfg, commits))
181
+ return r
182
+
183
+
184
+ def _landed(cfg: Config, commits: list[dict[str, Any]]) -> bool | None:
185
+ if not commits:
186
+ return False
187
+ target = cfg.default_branch if cfg.strategy == "local" else cfg.get("git.dev", "dev")
188
+ remote = cfg.get("provider.remote", "origin")
189
+ for ref in (f"{remote}/{target}", target):
190
+ if gitx.ok(cfg.root, "rev-parse", "--verify", ref):
191
+ return gitx.ok(cfg.root, "merge-base", "--is-ancestor", commits[0]["sha"], ref)
192
+ return None
193
+
194
+
195
+ # ---------------------------------------------------------------- other workflows
196
+
197
+
198
+ def security_wf(cfg: Config, module: str) -> Result:
199
+ from . import security
200
+
201
+ m = _module(cfg, module)
202
+ r = Result("security", module)
203
+ why = security.module_report_problem(cfg, m)
204
+ r.add("1", "module security report for the current files", not why or "assessment" in why, why)
205
+ r.add("2", "assessment written", bool(security.latest_module_report(m)) and "assessment" not in why, why if "assessment" in why else "")
206
+ p = security.latest_module_report(m)
207
+ r.add("3", "no blocking findings (or accepted risks)", bool(p) and "**Result:** PASS" in p.read_text())
208
+ return r
209
+
210
+
211
+ def change_request(cfg: Config, cr: str) -> Result:
212
+ from . import changes, decisions
213
+
214
+ path = changes.find(cfg, cr)
215
+ text = path.read_text()
216
+ r = Result("change-request", cr)
217
+ status = (re.search(r"^\*\*Status:\*\* `(\w+)`", text, re.M) or [None, "?"])[1]
218
+ r.add("1", "recorded with the user's request", md.PLACEHOLDER not in text.split("## Impact", 1)[0])
219
+ r.add("2", "impact written", md.PLACEHOLDER not in text.split("## Plan", 1)[0])
220
+ r.add("2", "assumptions resolved", not decisions.open_for(cfg, f"cr:{cr}"))
221
+
222
+ def section(name: str) -> str:
223
+ return text.split(f"## {name}", 1)[-1].split("\n## ", 1)[0] if f"## {name}" in text else ""
224
+
225
+ later = status in ("planned", "implemented", "tested", "reviewed", "done")
226
+ r.add("3", "specs updated (requirements changed)", bool(re.search(r"^- ", section("Requirements changed"), re.M)) if later else None)
227
+ r.add("4", "increments planned for it", bool(re.search(r"^- ", section("Plan"), re.M)) if later else None)
228
+ r.add("5", "status follows the increments", not [p for p in changes.cr_problems(cfg) if path.name in p])
229
+ return r
230
+
231
+
232
+ def fix(cfg: Config, item_id: str) -> Result:
233
+ from . import backlog, registry, reports
234
+
235
+ item = backlog.find(cfg, item_id)
236
+ r = Result("fix", item_id)
237
+ commits = _unit_commits(cfg, item_id, "Backlog")
238
+ r.add("1", "backlog item with priority and detail", md.PLACEHOLDER not in item.path.read_text())
239
+ done = item.status in backlog.NEEDS_FIX_ID
240
+ r.add("3", "fixed, with the fix change id", done and item.fix_change_id not in ("", "—") if item.status not in
241
+ ("wontfix", "duplicate", "accepted-risk") else None, f"status {item.status}") # fmt: skip
242
+ mods = registry.load(cfg)
243
+ mod = mods.get(item.module)
244
+ tested = mod and any(i.status in ("tested", "reviewed") for i in mod.increments)
245
+ regression = [p for p in reports.numbered(mod.folder / "test", "test_report") if item_id in p.read_text()] if mod else []
246
+ r.add("4", "regression test report for the tested module", bool(regression) if tested and done else None)
247
+ r.add("5", "commit with the Backlog trailer", bool(commits) if done else None)
248
+ r.add("5", "issue mirrored", item.issue_sync != "pending", item.issue_sync)
249
+ return r
250
+
251
+
252
+ def setup(cfg: Config) -> Result:
253
+ from . import decisions, guide, registry, security, style
254
+
255
+ r = Result("setup", cfg.project)
256
+ r.add("1", "project described and confirmed (architecture goals)",
257
+ cfg.architecture.exists() and md.PLACEHOLDER not in cfg.architecture.read_text().split("## 2.", 1)[0]) # fmt: skip
258
+ r.add("3", "setup answers recorded as decisions", any(d.scope == "project" for d in decisions.decisions(cfg)))
259
+ opened = decisions.open_for(cfg, "project")
260
+ r.add("6/7", "no open project assumptions", not opened, ", ".join(a.id for a in opened))
261
+ r.add("5", "guides exist", all(p.exists() for p in guide.guide_paths(cfg)))
262
+ r.add("5", "components specified (at least one spec)", bool(registry.load(cfg)))
263
+ r.add("6", "coding style agreed", style.state(cfg)["agreed"] if style.required(cfg) else None)
264
+ missing = [k for k in security.required(cfg) if k != "secrets" and not cfg.get(f"security.{k}") and not security.waiver(cfg, k)]
265
+ r.add("4", "required security checks configured or waived (security tools)", not missing, ", ".join(missing))
266
+ hooks = cfg.get("hooks.pre_commit") or cfg.get("hooks.pre_push")
267
+ r.add("4", "git hooks installed", gitx.out(cfg.root, "config", "core.hooksPath") != "" if hooks else None)
268
+ return r
269
+
270
+
271
+ def oneoff_wf(cfg: Config, where: str) -> Result:
272
+ from . import oneoff
273
+
274
+ path = oneoff.find(oneoff.base_dir(cfg.root, cfg.data), where)
275
+ st = oneoff.load(path)
276
+ r = Result("oneoff", st["name"])
277
+ r.add("2", "assumptions recorded instead of asking", bool(st["assumptions"]))
278
+ r.add("3", "plan: approach, tasks, test and run commands", bool(st["approach"] and st["tasks"] and st["test_commands"]
279
+ and st["run"])) # fmt: skip
280
+ r.add("4", "every task done", all(t["status"] == "done" for t in st["tasks"]))
281
+ r.add("5", "tests run and passed", bool(st["tests"]) and all(t["rc"] == 0 for t in st["tests"]))
282
+ r.add("6", "REPORT.md written after the last change", (path / "REPORT.md").exists() and
283
+ (path / "REPORT.md").stat().st_mtime >= (path / oneoff.STATE).stat().st_mtime - 5) # fmt: skip
284
+ r.add("4", "limitations and future scope noted", bool(st["limitations"] or st["future"]))
285
+ return r
286
+
287
+
288
+ def adopt_wf(cfg: Config) -> Result:
289
+ from . import adopt
290
+
291
+ r = Result("adopt", cfg.project)
292
+ w = adopt.work(cfg.root)
293
+ plan = json.loads((w / "plan.json").read_text()) if (w / "plan.json").exists() else {}
294
+ r.add("2", "repository scanned", (w / "analysis.json").exists() or bool(plan.get("applied")))
295
+ r.add("3", "plan agreed with the user", bool(plan.get("finalised")))
296
+ snap = plan.get("snapshot") or {}
297
+ r.add("4", "old code kept on a pushed branch", bool(snap.get("branch")) and bool(snap.get("pushed")),
298
+ snap.get("branch", "")) # fmt: skip
299
+ r.add("6", "applied: architecture, specs, baseline", bool(plan.get("applied")) and
300
+ (cfg.architecture_dir / "baseline.md").exists()) # fmt: skip
301
+ return r
302
+
303
+
304
+ def triage_wf(cfg: Config) -> Result:
305
+ from . import triage
306
+
307
+ r = Result("triage", cfg.project)
308
+ p = triage.data_path(cfg)
309
+ rows = json.loads(p.read_text())["rows"] if p.exists() else []
310
+ r.add("1", "issues fetched and checked against the specs", p.exists())
311
+ vague = [f"#{x['number']}" for x in rows if x["status"] == "vague"]
312
+ r.add("3/4", "every vague issue decided by the user", not vague, ", ".join(vague))
313
+ return r
314
+
315
+
316
+ def discuss_wf(cfg: Config, number: int) -> Result:
317
+ from . import discuss
318
+
319
+ st = discuss.load(cfg.root, number)
320
+ r = Result("discuss", f"issue #{number}")
321
+ r.add("2", "questions posted", any(p["type"] == "questions" for p in st["posts"]))
322
+ left = [q["id"] for q in st["questions"] if not q["answer"]]
323
+ r.add("3", "every question answered (verbatim)", not left, ", ".join(left))
324
+ r.add("4", "summary confirmed by the user", "summary" in st["approvals"])
325
+ r.add("5", "plan reviewed and approved by the user", "plan" in st["approvals"])
326
+ r.add("6", "applied to the specifications", st["phase"] == "applied", f"phase {st['phase']}")
327
+ return r
328
+
329
+
330
+ def deploy_wf(cfg: Config) -> Result:
331
+ from . import deploy
332
+
333
+ r = Result("deploy", cfg.project)
334
+ st = deploy.state(cfg)
335
+ r.add("1-3", "deploy plan agreed and applied", st.get("agreed", False))
336
+ probs = deploy.problems(cfg) if st.get("agreed") else []
337
+ r.add("4", "generated files in step with the plan, every module placed", not probs if st.get("agreed") else None,
338
+ "; ".join(probs[:2])) # fmt: skip
339
+ warn = deploy.credential_warnings(cfg)
340
+ r.add("6", "no deploy credentials on the agents' machine", not warn, "; ".join(warn))
341
+ return r
342
+
343
+
344
+ # ---------------------------------------------------------------- entry points
345
+
346
+
347
+ def run(cfg: Config, workflow: str, **kw: Any) -> Result:
348
+ if workflow in ("implement", "test", "review"):
349
+ if not kw.get("module") or not kw.get("increment"):
350
+ raise ValueError("--module and --increment")
351
+ fn = {"implement": implement, "test": test, "review": review}[workflow]
352
+ return fn(cfg, kw["module"], int(kw["increment"]), prepush=kw.get("prepush", False))
353
+ if workflow == "security":
354
+ return security_wf(cfg, kw["module"])
355
+ if workflow == "change-request":
356
+ return change_request(cfg, kw["cr"])
357
+ if workflow == "fix":
358
+ return fix(cfg, kw["backlog"])
359
+ if workflow == "setup":
360
+ return setup(cfg)
361
+ if workflow == "oneoff":
362
+ return oneoff_wf(cfg, kw.get("dir", ""))
363
+ if workflow == "adopt":
364
+ return adopt_wf(cfg)
365
+ if workflow == "triage":
366
+ return triage_wf(cfg)
367
+ if workflow == "discuss":
368
+ return discuss_wf(cfg, int(kw["issue"]))
369
+ if workflow == "deploy":
370
+ return deploy_wf(cfg)
371
+ raise ValueError(f"unknown workflow {workflow!r}; known: {', '.join(WORKFLOWS)}")
372
+
373
+
374
+ def stage_checks(cfg: Config, module: str, k: int, *, prepush: bool = False) -> list[Result]:
375
+ """The checks that apply to an increment's current status: coded → implement, tested → +test, reviewed → +review."""
376
+ from . import registry
377
+
378
+ inc = registry.load(cfg)[module].inc(k)
379
+ out = []
380
+ if inc.status in ("coded", "tested", "reviewed"):
381
+ out.append(implement(cfg, module, k, prepush=prepush))
382
+ if inc.status in ("tested", "reviewed"):
383
+ out.append(test(cfg, module, k, prepush=prepush))
384
+ if inc.status == "reviewed":
385
+ out.append(review(cfg, module, k, prepush=prepush))
386
+ return out
387
+
388
+
389
+ def report(cfg: Config) -> dict[str, Any]:
390
+ from . import backlog, changes, registry
391
+
392
+ results: list[Result] = []
393
+ for m in registry.load(cfg).values():
394
+ for inc in m.increments:
395
+ results += stage_checks(cfg, m.name, inc.number)
396
+ for item in backlog.items(cfg):
397
+ if item.status in backlog.NEEDS_FIX_ID:
398
+ results.append(fix(cfg, item.id))
399
+ for p in changes.all_crs(cfg):
400
+ cr = "-".join(p.name.split("-")[:2])
401
+ if not re.search(r"^\*\*Status:\*\* `(proposed|rejected)`", p.read_text(), re.M):
402
+ results.append(change_request(cfg, cr))
403
+ by: dict[str, dict[str, int]] = {}
404
+ for r in results:
405
+ b = by.setdefault(r.workflow, {"runs": 0, "passed": 0, "steps": 0, "missing": 0})
406
+ b["runs"] += 1
407
+ b["passed"] += r.passed
408
+ b["steps"] += len([s for s in r.steps if s.status != "n/a"])
409
+ b["missing"] += len(r.missing)
410
+ skipped: dict[str, int] = {}
411
+ for r in results:
412
+ for s in r.missing:
413
+ key = f"{r.workflow} step {s.n}: {s.name}"
414
+ skipped[key] = skipped.get(key, 0) + 1
415
+ return {"results": results, "by_workflow": by, "most_skipped": sorted(skipped.items(), key=lambda kv: -kv[1])[:10]}
416
+
417
+
418
+ def report_text(data: dict[str, Any]) -> str:
419
+ lines = ["| Workflow | Runs | Followed fully | Steps done |", "|---|---|---|---|"]
420
+ for wf, b in sorted(data["by_workflow"].items()):
421
+ pct = 100 * (b["steps"] - b["missing"]) / b["steps"] if b["steps"] else 100
422
+ lines.append(f"| {wf} | {b['runs']} | {b['passed']} | {pct:.0f}% |")
423
+ if data["most_skipped"]:
424
+ lines += ["", "Most skipped steps:", *[f"- {k} ({n}×)" for k, n in data["most_skipped"]]]
425
+ failing = [r for r in data["results"] if not r.passed]
426
+ if failing:
427
+ lines += ["", "Not followed fully:", *[f"- {r.workflow} {r.subject}: " + "; ".join(f"step {s.n} {s.name}" for s in r.missing)
428
+ for r in failing]] # fmt: skip
429
+ return "\n".join(lines)
430
+
431
+
432
+ def prepush(cfg: Config, branches: list[str]) -> list[str]:
433
+ """Pre-push: for each pushed unit branch, the checks of its increment's status must pass."""
434
+ from . import registry
435
+
436
+ failures = []
437
+ mods = registry.load(cfg)
438
+ rx = re.compile(rf"^{re.escape(cfg.prefix.lower())}-\d{{4}}-(?P<module>[\w-]+?)-inc(?P<k>\d+)$")
439
+ for b in branches:
440
+ m = rx.match(b)
441
+ if not m or m.group("module") not in mods or mods[m.group("module")].inc(int(m.group("k"))) is None:
442
+ continue
443
+ for r in stage_checks(cfg, m.group("module"), int(m.group("k")), prepush=True):
444
+ if not r.passed:
445
+ failures.append("workflow not followed: " + r.text())
446
+ return failures
447
+
448
+
449
+ def workflow_file(cfg: Config, name: str) -> Path:
450
+ from .config import RES
451
+
452
+ path = RES / "workflows" / f"{name}.md"
453
+ if not path.exists():
454
+ raise ValueError(f"no workflow {name!r}")
455
+ return path