coprogrammer 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.
- coprogrammer/__init__.py +5 -0
- coprogrammer/__main__.py +5 -0
- coprogrammer/cli.py +1875 -0
- coprogrammer/mcp_server.py +371 -0
- coprogrammer-0.1.0.dist-info/METADATA +250 -0
- coprogrammer-0.1.0.dist-info/RECORD +10 -0
- coprogrammer-0.1.0.dist-info/WHEEL +5 -0
- coprogrammer-0.1.0.dist-info/entry_points.txt +2 -0
- coprogrammer-0.1.0.dist-info/licenses/LICENSE +21 -0
- coprogrammer-0.1.0.dist-info/top_level.txt +1 -0
coprogrammer/cli.py
ADDED
|
@@ -0,0 +1,1875 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import fnmatch
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.error import HTTPError
|
|
13
|
+
from urllib.request import Request, urlopen
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
REQUIRED_MANIFEST_FIELDS = {
|
|
18
|
+
"task",
|
|
19
|
+
"intent",
|
|
20
|
+
"scope",
|
|
21
|
+
"core_changes",
|
|
22
|
+
"contracts",
|
|
23
|
+
"tests",
|
|
24
|
+
"risks",
|
|
25
|
+
}
|
|
26
|
+
REQUIRED_INTEGRATION_PLAN_FIELDS = {
|
|
27
|
+
"source_branch",
|
|
28
|
+
"source_head",
|
|
29
|
+
"main_base",
|
|
30
|
+
"objective",
|
|
31
|
+
"changes_to_rebuild",
|
|
32
|
+
"changes_to_drop",
|
|
33
|
+
"patch_primitives",
|
|
34
|
+
"validation",
|
|
35
|
+
"rollback_plan",
|
|
36
|
+
"status",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
DEFAULT_GITHUB_API_URL = "https://api.github.com"
|
|
40
|
+
DEFAULT_COMMENT_MARKER = "<!-- coprogrammer-branch-digest -->"
|
|
41
|
+
GITHUB_COMMENT_LIMIT = 65000
|
|
42
|
+
DEFAULT_LANGUAGE = "en"
|
|
43
|
+
DEFAULT_CONFIG_FILE = ".coprogrammer.json"
|
|
44
|
+
DEFAULT_MANAGER_DIR = ".coprogrammer"
|
|
45
|
+
DEFAULT_EVENT_LOG = "events.jsonl"
|
|
46
|
+
RISK_LEVELS = ("low", "medium", "high", "critical")
|
|
47
|
+
EVENT_TYPES = (
|
|
48
|
+
"agent.heartbeat",
|
|
49
|
+
"agent.blocked",
|
|
50
|
+
"lease.requested",
|
|
51
|
+
"lease.granted",
|
|
52
|
+
"lease.released",
|
|
53
|
+
"contract.change.proposed",
|
|
54
|
+
"branch.digest.created",
|
|
55
|
+
"decision.requested",
|
|
56
|
+
"decision.recorded",
|
|
57
|
+
"integration.plan.created",
|
|
58
|
+
"integration.recorded",
|
|
59
|
+
)
|
|
60
|
+
LEASE_KINDS = ("path", "contract", "test_surface", "integration_branch")
|
|
61
|
+
DECISION_RECORD_STATUSES = ("decided", "deferred", "rejected", "superseded")
|
|
62
|
+
CONTRACT_KINDS = (
|
|
63
|
+
"api",
|
|
64
|
+
"schema",
|
|
65
|
+
"database",
|
|
66
|
+
"shared_type",
|
|
67
|
+
"auth",
|
|
68
|
+
"payment",
|
|
69
|
+
"deployment",
|
|
70
|
+
"config",
|
|
71
|
+
"other",
|
|
72
|
+
)
|
|
73
|
+
CONTRACT_COMPATIBILITY = ("compatible", "breaking", "unknown")
|
|
74
|
+
AGENTS_FORBIDDEN_STATE_HEADINGS = {
|
|
75
|
+
"# backlog",
|
|
76
|
+
"## backlog",
|
|
77
|
+
"# current tasks",
|
|
78
|
+
"## current tasks",
|
|
79
|
+
"# current status",
|
|
80
|
+
"## current status",
|
|
81
|
+
"# active leases",
|
|
82
|
+
"## active leases",
|
|
83
|
+
"# open decisions",
|
|
84
|
+
"## open decisions",
|
|
85
|
+
"# decision log",
|
|
86
|
+
"## decision log",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
RISK_PATTERNS: dict[str, tuple[str, ...]] = {
|
|
90
|
+
"contract": (
|
|
91
|
+
"schema",
|
|
92
|
+
"schemas/",
|
|
93
|
+
"openapi",
|
|
94
|
+
"proto",
|
|
95
|
+
"graphql",
|
|
96
|
+
"shared",
|
|
97
|
+
"types",
|
|
98
|
+
),
|
|
99
|
+
"database": ("migration", "migrations/", "db/", "database"),
|
|
100
|
+
"security": ("auth", "permission", "role", "payment", "secret", ".env"),
|
|
101
|
+
"build": (
|
|
102
|
+
"package-lock",
|
|
103
|
+
"pnpm-lock",
|
|
104
|
+
"yarn.lock",
|
|
105
|
+
"poetry.lock",
|
|
106
|
+
"pyproject.toml",
|
|
107
|
+
"requirements",
|
|
108
|
+
),
|
|
109
|
+
"architecture": ("architecture", "constitution", "config", "settings"),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
DEFAULT_RISK_LEVEL_BY_CATEGORY = {
|
|
113
|
+
"contract": "medium",
|
|
114
|
+
"database": "high",
|
|
115
|
+
"security": "high",
|
|
116
|
+
"build": "medium",
|
|
117
|
+
"architecture": "medium",
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
121
|
+
"language": DEFAULT_LANGUAGE,
|
|
122
|
+
"risk_level_by_category": DEFAULT_RISK_LEVEL_BY_CATEGORY,
|
|
123
|
+
"protected_paths": [],
|
|
124
|
+
"model_routing": {},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
LANGUAGE_ALIASES = {
|
|
128
|
+
"en": "en",
|
|
129
|
+
"en-US": "en",
|
|
130
|
+
"zh": "zh-CN",
|
|
131
|
+
"zh-CN": "zh-CN",
|
|
132
|
+
"zh_CN": "zh-CN",
|
|
133
|
+
"cn": "zh-CN",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
RISK_LABELS = {
|
|
137
|
+
"en": {
|
|
138
|
+
"contract": "contract",
|
|
139
|
+
"database": "database",
|
|
140
|
+
"security": "security",
|
|
141
|
+
"build": "build",
|
|
142
|
+
"architecture": "architecture",
|
|
143
|
+
},
|
|
144
|
+
"zh-CN": {
|
|
145
|
+
"contract": "共享契约",
|
|
146
|
+
"database": "数据库",
|
|
147
|
+
"security": "安全/权限",
|
|
148
|
+
"build": "构建/依赖",
|
|
149
|
+
"architecture": "架构",
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
DIGEST_TEXT = {
|
|
154
|
+
"en": {
|
|
155
|
+
"title": "Branch Digest",
|
|
156
|
+
"generated": "Generated",
|
|
157
|
+
"branch_intent": "Branch Intent",
|
|
158
|
+
"branch_intent_todo": "TODO: Describe the problem this branch is trying to solve.",
|
|
159
|
+
"core_contribution": "Core Contribution",
|
|
160
|
+
"core_contribution_todo": (
|
|
161
|
+
"TODO: List the useful ideas, experiments, and implementation decisions "
|
|
162
|
+
"worth preserving."
|
|
163
|
+
),
|
|
164
|
+
"changed_files": "Changed Files",
|
|
165
|
+
"no_changed_files": "- No changed files detected.",
|
|
166
|
+
"commit_summary": "Commit Summary",
|
|
167
|
+
"no_commits": "- No commits detected.",
|
|
168
|
+
"signals": "Contract and Architecture Signals",
|
|
169
|
+
"no_signals": "- No high-signal risk paths detected by the first-pass rules.",
|
|
170
|
+
"risk_level": "Risk Level",
|
|
171
|
+
"protected_paths": "Protected Path Matches",
|
|
172
|
+
"no_protected_paths": "- No protected path matches detected.",
|
|
173
|
+
"owner_review": "owner review required",
|
|
174
|
+
"noise": "Noise / Non-Essential Changes",
|
|
175
|
+
"noise_todo": (
|
|
176
|
+
"TODO: Identify formatting churn, broad rewrites, temporary debugging, "
|
|
177
|
+
"generated artifacts, or unrelated refactors."
|
|
178
|
+
),
|
|
179
|
+
"integration_plan": "Integration Plan",
|
|
180
|
+
"integration_plan_todo": (
|
|
181
|
+
"TODO: Explain how to rebuild the smallest safe patch from latest main."
|
|
182
|
+
),
|
|
183
|
+
"validation_needed": "Validation Needed",
|
|
184
|
+
"validation_items": [
|
|
185
|
+
"Formatter",
|
|
186
|
+
"Linter",
|
|
187
|
+
"Type check",
|
|
188
|
+
"Unit tests",
|
|
189
|
+
"Integration tests",
|
|
190
|
+
"Contract tests",
|
|
191
|
+
"Owner review for protected areas",
|
|
192
|
+
],
|
|
193
|
+
"human_decisions": "Human Decisions",
|
|
194
|
+
"human_decisions_todo": "TODO: List decisions that should not be delegated to an autonomous agent.",
|
|
195
|
+
},
|
|
196
|
+
"zh-CN": {
|
|
197
|
+
"title": "分支消化报告",
|
|
198
|
+
"generated": "生成时间",
|
|
199
|
+
"branch_intent": "分支意图",
|
|
200
|
+
"branch_intent_todo": "TODO: 说明这个分支要解决的问题。",
|
|
201
|
+
"core_contribution": "核心贡献",
|
|
202
|
+
"core_contribution_todo": "TODO: 列出值得保留的思路、实验结果和实现决策。",
|
|
203
|
+
"changed_files": "变更文件",
|
|
204
|
+
"no_changed_files": "- 未检测到变更文件。",
|
|
205
|
+
"commit_summary": "提交摘要",
|
|
206
|
+
"no_commits": "- 未检测到提交。",
|
|
207
|
+
"signals": "契约与架构信号",
|
|
208
|
+
"no_signals": "- 第一轮规则未检测到高信号风险路径。",
|
|
209
|
+
"risk_level": "风险等级",
|
|
210
|
+
"protected_paths": "受保护路径命中",
|
|
211
|
+
"no_protected_paths": "- 未命中受保护路径。",
|
|
212
|
+
"owner_review": "需要 owner review",
|
|
213
|
+
"noise": "噪声 / 非必要变更",
|
|
214
|
+
"noise_todo": "TODO: 标记格式化扰动、大范围重写、临时调试、生成物或无关重构。",
|
|
215
|
+
"integration_plan": "融合计划",
|
|
216
|
+
"integration_plan_todo": "TODO: 说明如何基于最新 main 重建最小安全补丁。",
|
|
217
|
+
"validation_needed": "需要验证",
|
|
218
|
+
"validation_items": [
|
|
219
|
+
"格式化",
|
|
220
|
+
"Lint 检查",
|
|
221
|
+
"类型检查",
|
|
222
|
+
"单元测试",
|
|
223
|
+
"集成测试",
|
|
224
|
+
"契约测试",
|
|
225
|
+
"受保护区域 owner review",
|
|
226
|
+
],
|
|
227
|
+
"human_decisions": "需要人工判断",
|
|
228
|
+
"human_decisions_todo": "TODO: 列出不应交给自治 agent 决定的问题。",
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
COMMENT_TEXT = {
|
|
233
|
+
"en": {
|
|
234
|
+
"title": "CoProgrammer Branch Digest",
|
|
235
|
+
"footer": "Generated by CoProgrammer.",
|
|
236
|
+
"truncated": "... truncated; see the workflow artifact for the full digest.",
|
|
237
|
+
},
|
|
238
|
+
"zh-CN": {
|
|
239
|
+
"title": "CoProgrammer 分支消化报告",
|
|
240
|
+
"footer": "由 CoProgrammer 生成。",
|
|
241
|
+
"truncated": "... 内容已截断;完整报告请查看 workflow artifact。",
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def resolve_language(language: str | None = None) -> str:
|
|
247
|
+
requested = language or os.environ.get("COPROGRAMMER_LANGUAGE") or DEFAULT_LANGUAGE
|
|
248
|
+
resolved = LANGUAGE_ALIASES.get(requested)
|
|
249
|
+
if not resolved:
|
|
250
|
+
supported = ", ".join(sorted(set(LANGUAGE_ALIASES)))
|
|
251
|
+
raise RuntimeError(f"unsupported language '{requested}'. Supported: {supported}")
|
|
252
|
+
return resolved
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def merge_config(config: dict[str, Any] | None) -> dict[str, Any]:
|
|
256
|
+
merged = {
|
|
257
|
+
"language": DEFAULT_CONFIG["language"],
|
|
258
|
+
"risk_level_by_category": dict(DEFAULT_RISK_LEVEL_BY_CATEGORY),
|
|
259
|
+
"protected_paths": [],
|
|
260
|
+
"model_routing": {},
|
|
261
|
+
}
|
|
262
|
+
if not config:
|
|
263
|
+
return merged
|
|
264
|
+
|
|
265
|
+
if "language" in config:
|
|
266
|
+
merged["language"] = config["language"]
|
|
267
|
+
if isinstance(config.get("risk_level_by_category"), dict):
|
|
268
|
+
merged["risk_level_by_category"].update(config["risk_level_by_category"])
|
|
269
|
+
if isinstance(config.get("protected_paths"), list):
|
|
270
|
+
merged["protected_paths"] = config["protected_paths"]
|
|
271
|
+
if isinstance(config.get("model_routing"), dict):
|
|
272
|
+
merged["model_routing"] = config["model_routing"]
|
|
273
|
+
return merged
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def load_config(cwd: Path, config_path: str | None = None) -> dict[str, Any]:
|
|
277
|
+
path = Path(config_path or DEFAULT_CONFIG_FILE)
|
|
278
|
+
if not path.is_absolute():
|
|
279
|
+
path = cwd / path
|
|
280
|
+
if not path.exists():
|
|
281
|
+
return merge_config(None)
|
|
282
|
+
try:
|
|
283
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
284
|
+
except json.JSONDecodeError as exc:
|
|
285
|
+
raise RuntimeError(f"invalid config JSON: {path}: {exc}") from exc
|
|
286
|
+
ok, errors = validate_config_data(data)
|
|
287
|
+
if not ok:
|
|
288
|
+
joined = "; ".join(errors)
|
|
289
|
+
raise RuntimeError(f"invalid config {path}: {joined}")
|
|
290
|
+
return merge_config(data)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def validate_config_data(data: dict[str, Any]) -> tuple[bool, list[str]]:
|
|
294
|
+
errors: list[str] = []
|
|
295
|
+
allowed_keys = {
|
|
296
|
+
"$schema",
|
|
297
|
+
"language",
|
|
298
|
+
"risk_level_by_category",
|
|
299
|
+
"protected_paths",
|
|
300
|
+
"model_routing",
|
|
301
|
+
}
|
|
302
|
+
for key in data:
|
|
303
|
+
if key not in allowed_keys:
|
|
304
|
+
errors.append(f"unknown config key: {key}")
|
|
305
|
+
|
|
306
|
+
language = data.get("language")
|
|
307
|
+
if language is not None:
|
|
308
|
+
try:
|
|
309
|
+
resolve_language(str(language))
|
|
310
|
+
except RuntimeError as exc:
|
|
311
|
+
errors.append(str(exc))
|
|
312
|
+
|
|
313
|
+
risk_level_by_category = data.get("risk_level_by_category", {})
|
|
314
|
+
if risk_level_by_category and not isinstance(risk_level_by_category, dict):
|
|
315
|
+
errors.append("risk_level_by_category must be an object")
|
|
316
|
+
elif isinstance(risk_level_by_category, dict):
|
|
317
|
+
for category, level in risk_level_by_category.items():
|
|
318
|
+
if category not in RISK_PATTERNS:
|
|
319
|
+
errors.append(f"unknown risk category: {category}")
|
|
320
|
+
if level not in RISK_LEVELS:
|
|
321
|
+
errors.append(f"invalid risk level for {category}: {level}")
|
|
322
|
+
|
|
323
|
+
protected_paths = data.get("protected_paths", [])
|
|
324
|
+
if not isinstance(protected_paths, list):
|
|
325
|
+
errors.append("protected_paths must be an array")
|
|
326
|
+
else:
|
|
327
|
+
allowed_rule_keys = {"pattern", "risk", "reason", "owner_review"}
|
|
328
|
+
for index, rule in enumerate(protected_paths):
|
|
329
|
+
if not isinstance(rule, dict):
|
|
330
|
+
errors.append(f"protected_paths[{index}] must be an object")
|
|
331
|
+
continue
|
|
332
|
+
for key in rule:
|
|
333
|
+
if key not in allowed_rule_keys:
|
|
334
|
+
errors.append(f"protected_paths[{index}] has unknown key: {key}")
|
|
335
|
+
if not isinstance(rule.get("pattern"), str) or not rule.get("pattern"):
|
|
336
|
+
errors.append(f"protected_paths[{index}].pattern is required")
|
|
337
|
+
level = rule.get("risk", "high")
|
|
338
|
+
if level not in RISK_LEVELS:
|
|
339
|
+
errors.append(f"protected_paths[{index}].risk must be one of {', '.join(RISK_LEVELS)}")
|
|
340
|
+
if "owner_review" in rule and not isinstance(rule["owner_review"], bool):
|
|
341
|
+
errors.append(f"protected_paths[{index}].owner_review must be a boolean")
|
|
342
|
+
|
|
343
|
+
model_routing = data.get("model_routing", {})
|
|
344
|
+
if model_routing and not isinstance(model_routing, dict):
|
|
345
|
+
errors.append("model_routing must be an object")
|
|
346
|
+
elif isinstance(model_routing, dict):
|
|
347
|
+
allowed_model_keys = {
|
|
348
|
+
"simple_work_model",
|
|
349
|
+
"default_model",
|
|
350
|
+
"high_risk_model",
|
|
351
|
+
"simple_work_allowed",
|
|
352
|
+
"requires_human_review",
|
|
353
|
+
}
|
|
354
|
+
for key in model_routing:
|
|
355
|
+
if key not in allowed_model_keys:
|
|
356
|
+
errors.append(f"model_routing has unknown key: {key}")
|
|
357
|
+
for key in ("simple_work_model", "default_model", "high_risk_model"):
|
|
358
|
+
if key in model_routing and not isinstance(model_routing[key], str):
|
|
359
|
+
errors.append(f"model_routing.{key} must be a string")
|
|
360
|
+
for key in ("simple_work_allowed", "requires_human_review"):
|
|
361
|
+
if key in model_routing:
|
|
362
|
+
value = model_routing[key]
|
|
363
|
+
if not isinstance(value, list):
|
|
364
|
+
errors.append(f"model_routing.{key} must be a list")
|
|
365
|
+
elif not all(isinstance(item, str) for item in value):
|
|
366
|
+
errors.append(f"model_routing.{key} must contain strings")
|
|
367
|
+
|
|
368
|
+
return not errors, errors
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def validate_config(path: Path) -> tuple[bool, list[str]]:
|
|
372
|
+
try:
|
|
373
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
374
|
+
except json.JSONDecodeError as exc:
|
|
375
|
+
return False, [f"invalid JSON: {exc}"]
|
|
376
|
+
except OSError as exc:
|
|
377
|
+
return False, [str(exc)]
|
|
378
|
+
return validate_config_data(data)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def utc_now() -> str:
|
|
382
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def run_git(args: list[str], cwd: Path) -> str:
|
|
386
|
+
result = subprocess.run(
|
|
387
|
+
["git", *args],
|
|
388
|
+
cwd=cwd,
|
|
389
|
+
check=False,
|
|
390
|
+
text=True,
|
|
391
|
+
capture_output=True,
|
|
392
|
+
)
|
|
393
|
+
if result.returncode != 0:
|
|
394
|
+
raise RuntimeError(result.stderr.strip() or "git command failed")
|
|
395
|
+
return result.stdout.strip()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def get_changed_files(base: str, head: str, cwd: Path) -> list[dict[str, str]]:
|
|
399
|
+
output = run_git(["diff", "--name-status", f"{base}...{head}"], cwd)
|
|
400
|
+
return parse_name_status(output)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def get_working_tree_changed_files(base: str, cwd: Path) -> list[dict[str, str]]:
|
|
404
|
+
output = run_git(["diff", "--name-status", base], cwd)
|
|
405
|
+
files = parse_name_status(output)
|
|
406
|
+
known_paths = {item["path"] for item in files}
|
|
407
|
+
untracked = run_git(["ls-files", "--others", "--exclude-standard"], cwd)
|
|
408
|
+
for path in untracked.splitlines():
|
|
409
|
+
if path and path not in known_paths:
|
|
410
|
+
files.append({"status": "A", "path": path})
|
|
411
|
+
return files
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def parse_name_status(output: str) -> list[dict[str, str]]:
|
|
415
|
+
files: list[dict[str, str]] = []
|
|
416
|
+
for line in output.splitlines():
|
|
417
|
+
parts = line.split("\t")
|
|
418
|
+
if not parts:
|
|
419
|
+
continue
|
|
420
|
+
status = parts[0]
|
|
421
|
+
path = parts[-1]
|
|
422
|
+
files.append({"status": status, "path": path})
|
|
423
|
+
return files
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def get_commits(base: str, head: str, cwd: Path) -> list[str]:
|
|
427
|
+
output = run_git(["log", "--oneline", f"{base}..{head}"], cwd)
|
|
428
|
+
return [line for line in output.splitlines() if line.strip()]
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def classify_risks(paths: list[str]) -> dict[str, list[str]]:
|
|
432
|
+
risks: dict[str, list[str]] = {name: [] for name in RISK_PATTERNS}
|
|
433
|
+
for path in paths:
|
|
434
|
+
lowered = path.lower()
|
|
435
|
+
for risk_name, patterns in RISK_PATTERNS.items():
|
|
436
|
+
if any(pattern in lowered for pattern in patterns):
|
|
437
|
+
risks[risk_name].append(path)
|
|
438
|
+
return {name: sorted(set(matches)) for name, matches in risks.items() if matches}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def match_protected_paths(
|
|
442
|
+
paths: list[str],
|
|
443
|
+
config: dict[str, Any] | None = None,
|
|
444
|
+
) -> list[dict[str, Any]]:
|
|
445
|
+
config = merge_config(config)
|
|
446
|
+
matches: list[dict[str, Any]] = []
|
|
447
|
+
for path in paths:
|
|
448
|
+
for rule in config["protected_paths"]:
|
|
449
|
+
pattern = rule["pattern"]
|
|
450
|
+
if fnmatch.fnmatch(path, pattern):
|
|
451
|
+
matches.append(
|
|
452
|
+
{
|
|
453
|
+
"path": path,
|
|
454
|
+
"pattern": pattern,
|
|
455
|
+
"risk": rule.get("risk", "high"),
|
|
456
|
+
"reason": rule.get("reason", ""),
|
|
457
|
+
"owner_review": rule.get("owner_review", True),
|
|
458
|
+
}
|
|
459
|
+
)
|
|
460
|
+
return matches
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def highest_risk_level(levels: list[str]) -> str:
|
|
464
|
+
if not levels:
|
|
465
|
+
return "low"
|
|
466
|
+
return max(levels, key=RISK_LEVELS.index)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def score_risk(
|
|
470
|
+
risks: dict[str, list[str]],
|
|
471
|
+
protected_matches: list[dict[str, Any]],
|
|
472
|
+
config: dict[str, Any] | None = None,
|
|
473
|
+
) -> str:
|
|
474
|
+
config = merge_config(config)
|
|
475
|
+
levels: list[str] = []
|
|
476
|
+
for category, matches in risks.items():
|
|
477
|
+
if matches:
|
|
478
|
+
levels.append(config["risk_level_by_category"].get(category, "medium"))
|
|
479
|
+
levels.extend(match["risk"] for match in protected_matches)
|
|
480
|
+
return highest_risk_level(levels)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def render_digest(
|
|
484
|
+
base: str,
|
|
485
|
+
head: str,
|
|
486
|
+
files: list[dict[str, str]],
|
|
487
|
+
commits: list[str],
|
|
488
|
+
language: str = DEFAULT_LANGUAGE,
|
|
489
|
+
config: dict[str, Any] | None = None,
|
|
490
|
+
) -> str:
|
|
491
|
+
language = resolve_language(language)
|
|
492
|
+
config = merge_config(config)
|
|
493
|
+
text = DIGEST_TEXT[language]
|
|
494
|
+
paths = [item["path"] for item in files]
|
|
495
|
+
risks = classify_risks(paths)
|
|
496
|
+
protected_matches = match_protected_paths(paths, config)
|
|
497
|
+
risk_level = score_risk(risks, protected_matches, config)
|
|
498
|
+
generated_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
499
|
+
|
|
500
|
+
changed_files = "\n".join(
|
|
501
|
+
f"- `{item['status']}` `{item['path']}`" for item in files
|
|
502
|
+
) or text["no_changed_files"]
|
|
503
|
+
commit_lines = "\n".join(f"- {commit}" for commit in commits) or text["no_commits"]
|
|
504
|
+
|
|
505
|
+
if risks:
|
|
506
|
+
risk_lines = []
|
|
507
|
+
risk_labels = RISK_LABELS[language]
|
|
508
|
+
for risk_name, matches in risks.items():
|
|
509
|
+
risk_lines.append(f"- **{risk_labels[risk_name]}**")
|
|
510
|
+
risk_lines.extend(f" - `{path}`" for path in matches)
|
|
511
|
+
risk_block = "\n".join(risk_lines)
|
|
512
|
+
else:
|
|
513
|
+
risk_block = text["no_signals"]
|
|
514
|
+
|
|
515
|
+
if protected_matches:
|
|
516
|
+
protected_lines = []
|
|
517
|
+
for match in protected_matches:
|
|
518
|
+
suffix = []
|
|
519
|
+
if match["reason"]:
|
|
520
|
+
suffix.append(match["reason"])
|
|
521
|
+
if match["owner_review"]:
|
|
522
|
+
suffix.append(text["owner_review"])
|
|
523
|
+
detail = f" ({'; '.join(suffix)})" if suffix else ""
|
|
524
|
+
protected_lines.append(
|
|
525
|
+
f"- `{match['path']}` matches `{match['pattern']}` "
|
|
526
|
+
f"[{match['risk']}]{detail}"
|
|
527
|
+
)
|
|
528
|
+
protected_block = "\n".join(protected_lines)
|
|
529
|
+
else:
|
|
530
|
+
protected_block = text["no_protected_paths"]
|
|
531
|
+
|
|
532
|
+
validation_items = "\n".join(f"- [ ] {item}" for item in text["validation_items"])
|
|
533
|
+
|
|
534
|
+
return f"""# {text["title"]}
|
|
535
|
+
|
|
536
|
+
{text["generated"]}: `{generated_at}`
|
|
537
|
+
|
|
538
|
+
Base: `{base}`
|
|
539
|
+
Head: `{head}`
|
|
540
|
+
|
|
541
|
+
## {text["branch_intent"]}
|
|
542
|
+
|
|
543
|
+
{text["branch_intent_todo"]}
|
|
544
|
+
|
|
545
|
+
## {text["core_contribution"]}
|
|
546
|
+
|
|
547
|
+
{text["core_contribution_todo"]}
|
|
548
|
+
|
|
549
|
+
## {text["changed_files"]}
|
|
550
|
+
|
|
551
|
+
{changed_files}
|
|
552
|
+
|
|
553
|
+
## {text["commit_summary"]}
|
|
554
|
+
|
|
555
|
+
{commit_lines}
|
|
556
|
+
|
|
557
|
+
## {text["signals"]}
|
|
558
|
+
|
|
559
|
+
{risk_block}
|
|
560
|
+
|
|
561
|
+
## {text["risk_level"]}
|
|
562
|
+
|
|
563
|
+
`{risk_level}`
|
|
564
|
+
|
|
565
|
+
## {text["protected_paths"]}
|
|
566
|
+
|
|
567
|
+
{protected_block}
|
|
568
|
+
|
|
569
|
+
## {text["noise"]}
|
|
570
|
+
|
|
571
|
+
{text["noise_todo"]}
|
|
572
|
+
|
|
573
|
+
## {text["integration_plan"]}
|
|
574
|
+
|
|
575
|
+
{text["integration_plan_todo"]}
|
|
576
|
+
|
|
577
|
+
## {text["validation_needed"]}
|
|
578
|
+
|
|
579
|
+
{validation_items}
|
|
580
|
+
|
|
581
|
+
## {text["human_decisions"]}
|
|
582
|
+
|
|
583
|
+
{text["human_decisions_todo"]}
|
|
584
|
+
"""
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def validate_manifest(path: Path) -> tuple[bool, list[str]]:
|
|
588
|
+
try:
|
|
589
|
+
payload: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
590
|
+
except json.JSONDecodeError as exc:
|
|
591
|
+
return False, [f"invalid JSON: {exc}"]
|
|
592
|
+
except OSError as exc:
|
|
593
|
+
return False, [str(exc)]
|
|
594
|
+
|
|
595
|
+
missing = sorted(REQUIRED_MANIFEST_FIELDS - set(payload))
|
|
596
|
+
errors = [f"missing required field: {field}" for field in missing]
|
|
597
|
+
|
|
598
|
+
if not isinstance(payload.get("core_changes", []), list):
|
|
599
|
+
errors.append("core_changes must be a list")
|
|
600
|
+
if not isinstance(payload.get("contracts", []), list):
|
|
601
|
+
errors.append("contracts must be a list")
|
|
602
|
+
if not isinstance(payload.get("tests", []), list):
|
|
603
|
+
errors.append("tests must be a list")
|
|
604
|
+
if not isinstance(payload.get("risks", []), list):
|
|
605
|
+
errors.append("risks must be a list")
|
|
606
|
+
|
|
607
|
+
return not errors, errors
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def validate_integration_plan(path: Path) -> tuple[bool, list[str]]:
|
|
611
|
+
try:
|
|
612
|
+
payload: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
613
|
+
except json.JSONDecodeError as exc:
|
|
614
|
+
return False, [f"invalid JSON: {exc}"]
|
|
615
|
+
except OSError as exc:
|
|
616
|
+
return False, [str(exc)]
|
|
617
|
+
|
|
618
|
+
missing = sorted(REQUIRED_INTEGRATION_PLAN_FIELDS - set(payload))
|
|
619
|
+
errors = [f"missing required field: {field}" for field in missing]
|
|
620
|
+
|
|
621
|
+
for field in (
|
|
622
|
+
"changes_to_rebuild",
|
|
623
|
+
"changes_to_drop",
|
|
624
|
+
"patch_primitives",
|
|
625
|
+
"validation",
|
|
626
|
+
):
|
|
627
|
+
if field in payload and not isinstance(payload[field], list):
|
|
628
|
+
errors.append(f"{field} must be a list")
|
|
629
|
+
|
|
630
|
+
if "rollback_plan" in payload and not isinstance(payload["rollback_plan"], str):
|
|
631
|
+
errors.append("rollback_plan must be a string")
|
|
632
|
+
|
|
633
|
+
status = payload.get("status")
|
|
634
|
+
if status is not None and status not in {
|
|
635
|
+
"draft",
|
|
636
|
+
"approved",
|
|
637
|
+
"applied",
|
|
638
|
+
"superseded",
|
|
639
|
+
}:
|
|
640
|
+
errors.append("status must be one of draft, approved, applied, superseded")
|
|
641
|
+
|
|
642
|
+
return not errors, errors
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def validate_agents_file(path: Path, max_lines: int = 120) -> tuple[bool, list[str]]:
|
|
646
|
+
errors: list[str] = []
|
|
647
|
+
try:
|
|
648
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
649
|
+
except OSError as exc:
|
|
650
|
+
return False, [str(exc)]
|
|
651
|
+
|
|
652
|
+
if len(lines) > max_lines:
|
|
653
|
+
errors.append(
|
|
654
|
+
f"AGENTS.md is too long: {len(lines)} lines; expected <= {max_lines}"
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
headings = {
|
|
658
|
+
line.strip().lower()
|
|
659
|
+
for line in lines
|
|
660
|
+
if line.lstrip().startswith("#")
|
|
661
|
+
}
|
|
662
|
+
forbidden = sorted(headings & AGENTS_FORBIDDEN_STATE_HEADINGS)
|
|
663
|
+
for heading in forbidden:
|
|
664
|
+
errors.append(
|
|
665
|
+
f"AGENTS.md should not store live coordination state in heading: {heading}"
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
return not errors, errors
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def new_heartbeat(agent: str, task: str) -> dict[str, Any]:
|
|
672
|
+
now = utc_now()
|
|
673
|
+
return {
|
|
674
|
+
"agent": agent,
|
|
675
|
+
"task": task,
|
|
676
|
+
"timestamp": now,
|
|
677
|
+
"status": "working",
|
|
678
|
+
"currently_editing": [],
|
|
679
|
+
"next_step": "",
|
|
680
|
+
"contracts_touched": [],
|
|
681
|
+
"blockers": [],
|
|
682
|
+
"insights": [],
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def manager_dir(cwd: Path, state_dir: str = DEFAULT_MANAGER_DIR) -> Path:
|
|
687
|
+
path = Path(state_dir)
|
|
688
|
+
if not path.is_absolute():
|
|
689
|
+
path = cwd / path
|
|
690
|
+
return path
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def event_log_path(cwd: Path, state_dir: str = DEFAULT_MANAGER_DIR) -> Path:
|
|
694
|
+
return manager_dir(cwd, state_dir) / DEFAULT_EVENT_LOG
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def make_event(
|
|
698
|
+
event_type: str,
|
|
699
|
+
actor: str,
|
|
700
|
+
subject: str,
|
|
701
|
+
payload: dict[str, Any] | None = None,
|
|
702
|
+
) -> dict[str, Any]:
|
|
703
|
+
return {
|
|
704
|
+
"id": f"evt_{uuid4().hex}",
|
|
705
|
+
"type": event_type,
|
|
706
|
+
"timestamp": utc_now(),
|
|
707
|
+
"actor": actor,
|
|
708
|
+
"subject": subject,
|
|
709
|
+
"payload": payload or {},
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def append_event(path: Path, event: dict[str, Any]) -> None:
|
|
714
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
715
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
716
|
+
handle.write(json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n")
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def load_events(path: Path) -> list[dict[str, Any]]:
|
|
720
|
+
if not path.exists():
|
|
721
|
+
return []
|
|
722
|
+
|
|
723
|
+
events: list[dict[str, Any]] = []
|
|
724
|
+
with path.open(encoding="utf-8") as handle:
|
|
725
|
+
for line_number, line in enumerate(handle, start=1):
|
|
726
|
+
stripped = line.strip()
|
|
727
|
+
if not stripped:
|
|
728
|
+
continue
|
|
729
|
+
try:
|
|
730
|
+
event = json.loads(stripped)
|
|
731
|
+
except json.JSONDecodeError as exc:
|
|
732
|
+
raise RuntimeError(
|
|
733
|
+
f"invalid event log JSON at line {line_number}: {exc}"
|
|
734
|
+
) from exc
|
|
735
|
+
if not isinstance(event, dict):
|
|
736
|
+
raise RuntimeError(
|
|
737
|
+
f"invalid event log entry at line {line_number}: expected object"
|
|
738
|
+
)
|
|
739
|
+
events.append(event)
|
|
740
|
+
return events
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def pattern_static_prefix(pattern: str) -> str:
|
|
744
|
+
magic_positions = [
|
|
745
|
+
position
|
|
746
|
+
for token in ("*", "?", "[")
|
|
747
|
+
if (position := pattern.find(token)) != -1
|
|
748
|
+
]
|
|
749
|
+
if not magic_positions:
|
|
750
|
+
return pattern.rstrip("/")
|
|
751
|
+
return pattern[: min(magic_positions)].rstrip("/")
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def patterns_overlap(left: str, right: str) -> bool:
|
|
755
|
+
if fnmatch.fnmatch(left, right) or fnmatch.fnmatch(right, left):
|
|
756
|
+
return True
|
|
757
|
+
|
|
758
|
+
left_prefix = pattern_static_prefix(left)
|
|
759
|
+
right_prefix = pattern_static_prefix(right)
|
|
760
|
+
if not left_prefix or not right_prefix:
|
|
761
|
+
return False
|
|
762
|
+
return (
|
|
763
|
+
left_prefix == right_prefix
|
|
764
|
+
or left_prefix.startswith(f"{right_prefix}/")
|
|
765
|
+
or right_prefix.startswith(f"{left_prefix}/")
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def lease_scopes_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
|
770
|
+
if left.get("kind") != right.get("kind"):
|
|
771
|
+
return False
|
|
772
|
+
for left_pattern in left.get("patterns", []):
|
|
773
|
+
for right_pattern in right.get("patterns", []):
|
|
774
|
+
if patterns_overlap(str(left_pattern), str(right_pattern)):
|
|
775
|
+
return True
|
|
776
|
+
return False
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def active_leases(events: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
780
|
+
leases: dict[str, dict[str, Any]] = {}
|
|
781
|
+
for event in events:
|
|
782
|
+
payload = event.get("payload", {})
|
|
783
|
+
if event.get("type") == "lease.granted":
|
|
784
|
+
lease = payload.get("lease", {})
|
|
785
|
+
lease_id = lease.get("id")
|
|
786
|
+
if lease_id:
|
|
787
|
+
leases[str(lease_id)] = lease
|
|
788
|
+
elif event.get("type") == "lease.released":
|
|
789
|
+
lease_id = payload.get("lease_id")
|
|
790
|
+
if lease_id:
|
|
791
|
+
leases.pop(str(lease_id), None)
|
|
792
|
+
return leases
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def open_decisions(events: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
796
|
+
decisions: dict[str, dict[str, Any]] = {}
|
|
797
|
+
for event in events:
|
|
798
|
+
payload = event.get("payload", {})
|
|
799
|
+
if event.get("type") == "decision.requested":
|
|
800
|
+
decision = payload.get("decision", {})
|
|
801
|
+
decision_id = decision.get("id")
|
|
802
|
+
if decision_id:
|
|
803
|
+
decisions[str(decision_id)] = decision
|
|
804
|
+
elif event.get("type") == "decision.recorded":
|
|
805
|
+
decision_id = payload.get("decision_id")
|
|
806
|
+
if decision_id:
|
|
807
|
+
decisions.pop(str(decision_id), None)
|
|
808
|
+
return decisions
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def latest_heartbeats(events: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
812
|
+
heartbeats: dict[str, dict[str, Any]] = {}
|
|
813
|
+
for event in events:
|
|
814
|
+
if event.get("type") != "agent.heartbeat":
|
|
815
|
+
continue
|
|
816
|
+
heartbeat = event.get("payload", {}).get("heartbeat", {})
|
|
817
|
+
agent = heartbeat.get("agent") or event.get("actor")
|
|
818
|
+
if agent:
|
|
819
|
+
heartbeats[str(agent)] = heartbeat
|
|
820
|
+
return heartbeats
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def contract_changes(events: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
824
|
+
changes: dict[str, dict[str, Any]] = {}
|
|
825
|
+
for event in events:
|
|
826
|
+
if event.get("type") != "contract.change.proposed":
|
|
827
|
+
continue
|
|
828
|
+
change = event.get("payload", {}).get("contract_change", {})
|
|
829
|
+
change_id = change.get("id")
|
|
830
|
+
if change_id:
|
|
831
|
+
changes[str(change_id)] = change
|
|
832
|
+
return changes
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def lease_overlap_pairs(
|
|
836
|
+
leases: dict[str, dict[str, Any]],
|
|
837
|
+
) -> list[dict[str, Any]]:
|
|
838
|
+
"""Find pairs of active leases held by different agents whose scopes overlap."""
|
|
839
|
+
items = list(leases.values())
|
|
840
|
+
pairs: list[dict[str, Any]] = []
|
|
841
|
+
for index, left in enumerate(items):
|
|
842
|
+
for right in items[index + 1 :]:
|
|
843
|
+
if left.get("holder") == right.get("holder"):
|
|
844
|
+
continue
|
|
845
|
+
if lease_scopes_overlap(left.get("scope", {}), right.get("scope", {})):
|
|
846
|
+
pairs.append(
|
|
847
|
+
{
|
|
848
|
+
"left_lease": left.get("id"),
|
|
849
|
+
"left_holder": left.get("holder"),
|
|
850
|
+
"left_patterns": left.get("scope", {}).get("patterns", []),
|
|
851
|
+
"right_lease": right.get("id"),
|
|
852
|
+
"right_holder": right.get("holder"),
|
|
853
|
+
"right_patterns": right.get("scope", {}).get("patterns", []),
|
|
854
|
+
"kind": left.get("scope", {}).get("kind"),
|
|
855
|
+
"severity": "medium",
|
|
856
|
+
}
|
|
857
|
+
)
|
|
858
|
+
return pairs
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def forecast_report(
|
|
862
|
+
events: list[dict[str, Any]],
|
|
863
|
+
config: dict[str, Any] | None = None,
|
|
864
|
+
changed_files: list[str] | None = None,
|
|
865
|
+
) -> dict[str, Any]:
|
|
866
|
+
"""Forecast path, contract, and protected-path conflicts before PR time.
|
|
867
|
+
|
|
868
|
+
The goal is to move conflict discovery from merge time to coding time:
|
|
869
|
+
overlapping leases predict path conflicts, proposed contract changes with
|
|
870
|
+
breaking/unknown compatibility predict semantic conflicts, and leases that
|
|
871
|
+
overlap protected paths predict owner-review pressure.
|
|
872
|
+
"""
|
|
873
|
+
config = merge_config(config)
|
|
874
|
+
leases = active_leases(events)
|
|
875
|
+
severity_levels: list[str] = []
|
|
876
|
+
|
|
877
|
+
path_conflicts = lease_overlap_pairs(leases)
|
|
878
|
+
severity_levels.extend(item["severity"] for item in path_conflicts)
|
|
879
|
+
|
|
880
|
+
contract_pressure: list[dict[str, Any]] = []
|
|
881
|
+
for change in contract_changes(events).values():
|
|
882
|
+
compatibility = change.get("compatibility", "unknown")
|
|
883
|
+
if compatibility == "compatible":
|
|
884
|
+
continue
|
|
885
|
+
severity = "high" if compatibility == "breaking" else "medium"
|
|
886
|
+
contract_pressure.append(
|
|
887
|
+
{
|
|
888
|
+
"id": change.get("id"),
|
|
889
|
+
"proposer": change.get("proposer"),
|
|
890
|
+
"kind": change.get("kind"),
|
|
891
|
+
"name": change.get("name"),
|
|
892
|
+
"compatibility": compatibility,
|
|
893
|
+
"severity": severity,
|
|
894
|
+
}
|
|
895
|
+
)
|
|
896
|
+
severity_levels.append(severity)
|
|
897
|
+
|
|
898
|
+
protected_pressure: list[dict[str, Any]] = []
|
|
899
|
+
for lease in leases.values():
|
|
900
|
+
for pattern in lease.get("scope", {}).get("patterns", []):
|
|
901
|
+
for rule in config["protected_paths"]:
|
|
902
|
+
if patterns_overlap(str(pattern), str(rule["pattern"])):
|
|
903
|
+
risk = rule.get("risk", "high")
|
|
904
|
+
protected_pressure.append(
|
|
905
|
+
{
|
|
906
|
+
"lease": lease.get("id"),
|
|
907
|
+
"holder": lease.get("holder"),
|
|
908
|
+
"lease_pattern": pattern,
|
|
909
|
+
"protected_pattern": rule["pattern"],
|
|
910
|
+
"risk": risk,
|
|
911
|
+
"owner_review": rule.get("owner_review", True),
|
|
912
|
+
"reason": rule.get("reason", ""),
|
|
913
|
+
}
|
|
914
|
+
)
|
|
915
|
+
severity_levels.append(risk)
|
|
916
|
+
|
|
917
|
+
changed_file_findings: list[dict[str, Any]] = []
|
|
918
|
+
for path in changed_files or []:
|
|
919
|
+
overlapping = [
|
|
920
|
+
{
|
|
921
|
+
"lease": lease.get("id"),
|
|
922
|
+
"holder": lease.get("holder"),
|
|
923
|
+
"patterns": lease.get("scope", {}).get("patterns", []),
|
|
924
|
+
}
|
|
925
|
+
for lease in leases.values()
|
|
926
|
+
if lease.get("scope", {}).get("kind") == "path"
|
|
927
|
+
and any(
|
|
928
|
+
patterns_overlap(path, str(pattern))
|
|
929
|
+
for pattern in lease.get("scope", {}).get("patterns", [])
|
|
930
|
+
)
|
|
931
|
+
]
|
|
932
|
+
protected = match_protected_paths([path], config)
|
|
933
|
+
if overlapping or protected:
|
|
934
|
+
changed_file_findings.append(
|
|
935
|
+
{
|
|
936
|
+
"path": path,
|
|
937
|
+
"overlapping_leases": overlapping,
|
|
938
|
+
"protected_matches": protected,
|
|
939
|
+
}
|
|
940
|
+
)
|
|
941
|
+
if len(overlapping) > 1:
|
|
942
|
+
severity_levels.append("medium")
|
|
943
|
+
severity_levels.extend(match["risk"] for match in protected)
|
|
944
|
+
|
|
945
|
+
return {
|
|
946
|
+
"generated_at": utc_now(),
|
|
947
|
+
"active_lease_count": len(leases),
|
|
948
|
+
"path_conflicts": path_conflicts,
|
|
949
|
+
"contract_pressure": contract_pressure,
|
|
950
|
+
"protected_pressure": protected_pressure,
|
|
951
|
+
"changed_file_findings": changed_file_findings,
|
|
952
|
+
"risk_level": highest_risk_level(severity_levels),
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def find_lease_conflicts(
|
|
957
|
+
requested_lease: dict[str, Any],
|
|
958
|
+
leases: dict[str, dict[str, Any]],
|
|
959
|
+
) -> list[dict[str, Any]]:
|
|
960
|
+
conflicts: list[dict[str, Any]] = []
|
|
961
|
+
requested_scope = requested_lease.get("scope", {})
|
|
962
|
+
for lease in leases.values():
|
|
963
|
+
if lease_scopes_overlap(requested_scope, lease.get("scope", {})):
|
|
964
|
+
conflicts.append(lease)
|
|
965
|
+
return conflicts
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def new_lease(
|
|
969
|
+
holder: str,
|
|
970
|
+
kind: str,
|
|
971
|
+
patterns: list[str],
|
|
972
|
+
task: str = "",
|
|
973
|
+
) -> dict[str, Any]:
|
|
974
|
+
return {
|
|
975
|
+
"id": f"lease_{uuid4().hex}",
|
|
976
|
+
"holder": holder,
|
|
977
|
+
"task": task,
|
|
978
|
+
"scope": {"kind": kind, "patterns": patterns},
|
|
979
|
+
"status": "requested",
|
|
980
|
+
"created_at": utc_now(),
|
|
981
|
+
"reason": "",
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def new_decision(
|
|
986
|
+
question: str,
|
|
987
|
+
context: str,
|
|
988
|
+
related_artifacts: list[str] | None = None,
|
|
989
|
+
risk: str = "medium",
|
|
990
|
+
) -> dict[str, Any]:
|
|
991
|
+
return {
|
|
992
|
+
"id": f"decision_{uuid4().hex}",
|
|
993
|
+
"question": question,
|
|
994
|
+
"context": context,
|
|
995
|
+
"options": [],
|
|
996
|
+
"decision": "",
|
|
997
|
+
"decider": "",
|
|
998
|
+
"status": "open",
|
|
999
|
+
"risk": risk,
|
|
1000
|
+
"created_at": utc_now(),
|
|
1001
|
+
"related_artifacts": related_artifacts or [],
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def new_contract_change(
|
|
1006
|
+
proposer: str,
|
|
1007
|
+
kind: str,
|
|
1008
|
+
name: str,
|
|
1009
|
+
summary: str,
|
|
1010
|
+
compatibility: str = "unknown",
|
|
1011
|
+
affected_artifacts: list[str] | None = None,
|
|
1012
|
+
) -> dict[str, Any]:
|
|
1013
|
+
return {
|
|
1014
|
+
"id": f"contract_{uuid4().hex}",
|
|
1015
|
+
"proposer": proposer,
|
|
1016
|
+
"kind": kind,
|
|
1017
|
+
"name": name,
|
|
1018
|
+
"summary": summary,
|
|
1019
|
+
"compatibility": compatibility,
|
|
1020
|
+
"status": "proposed",
|
|
1021
|
+
"created_at": utc_now(),
|
|
1022
|
+
"affected_artifacts": affected_artifacts or [],
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def build_pr_comment_body(
|
|
1027
|
+
digest: str,
|
|
1028
|
+
marker: str = DEFAULT_COMMENT_MARKER,
|
|
1029
|
+
language: str = DEFAULT_LANGUAGE,
|
|
1030
|
+
) -> str:
|
|
1031
|
+
language = resolve_language(language)
|
|
1032
|
+
text = COMMENT_TEXT[language]
|
|
1033
|
+
header = f"{marker}\n## {text['title']}\n\n"
|
|
1034
|
+
footer = f"\n\n_{text['footer']}_\n"
|
|
1035
|
+
truncation_notice = f"\n\n{text['truncated']}\n"
|
|
1036
|
+
budget = GITHUB_COMMENT_LIMIT - len(header) - len(footer) - len(truncation_notice)
|
|
1037
|
+
|
|
1038
|
+
if len(digest) > budget:
|
|
1039
|
+
digest = digest[:budget].rstrip() + truncation_notice
|
|
1040
|
+
|
|
1041
|
+
return header + digest + footer
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def load_pull_request_number(event_path: Path) -> int:
|
|
1045
|
+
try:
|
|
1046
|
+
payload: dict[str, Any] = json.loads(event_path.read_text(encoding="utf-8"))
|
|
1047
|
+
return int(payload["pull_request"]["number"])
|
|
1048
|
+
except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
1049
|
+
raise RuntimeError("could not read pull request number from GitHub event") from exc
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def github_request(
|
|
1053
|
+
method: str,
|
|
1054
|
+
url: str,
|
|
1055
|
+
token: str,
|
|
1056
|
+
payload: dict[str, Any] | None = None,
|
|
1057
|
+
) -> Any:
|
|
1058
|
+
data = None
|
|
1059
|
+
if payload is not None:
|
|
1060
|
+
data = json.dumps(payload).encode("utf-8")
|
|
1061
|
+
|
|
1062
|
+
request = Request(
|
|
1063
|
+
url,
|
|
1064
|
+
data=data,
|
|
1065
|
+
method=method,
|
|
1066
|
+
headers={
|
|
1067
|
+
"Accept": "application/vnd.github+json",
|
|
1068
|
+
"Authorization": f"Bearer {token}",
|
|
1069
|
+
"Content-Type": "application/json",
|
|
1070
|
+
"User-Agent": "coprogrammer",
|
|
1071
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
1072
|
+
},
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
try:
|
|
1076
|
+
with urlopen(request, timeout=20) as response:
|
|
1077
|
+
text = response.read().decode("utf-8")
|
|
1078
|
+
except HTTPError as exc:
|
|
1079
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
1080
|
+
raise RuntimeError(f"GitHub API request failed: {exc.code} {body}") from exc
|
|
1081
|
+
|
|
1082
|
+
if not text:
|
|
1083
|
+
return None
|
|
1084
|
+
return json.loads(text)
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def upsert_pr_comment(
|
|
1088
|
+
repository: str,
|
|
1089
|
+
pull_number: int,
|
|
1090
|
+
token: str,
|
|
1091
|
+
body: str,
|
|
1092
|
+
marker: str = DEFAULT_COMMENT_MARKER,
|
|
1093
|
+
api_url: str = DEFAULT_GITHUB_API_URL,
|
|
1094
|
+
) -> str:
|
|
1095
|
+
comments_url = f"{api_url}/repos/{repository}/issues/{pull_number}/comments?per_page=100"
|
|
1096
|
+
comments = github_request("GET", comments_url, token)
|
|
1097
|
+
for comment in comments:
|
|
1098
|
+
if marker in comment.get("body", ""):
|
|
1099
|
+
comment_url = f"{api_url}/repos/{repository}/issues/comments/{comment['id']}"
|
|
1100
|
+
github_request("PATCH", comment_url, token, {"body": body})
|
|
1101
|
+
return "updated"
|
|
1102
|
+
|
|
1103
|
+
create_url = f"{api_url}/repos/{repository}/issues/{pull_number}/comments"
|
|
1104
|
+
github_request("POST", create_url, token, {"body": body})
|
|
1105
|
+
return "created"
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def command_digest(args: argparse.Namespace) -> int:
|
|
1109
|
+
cwd = Path(args.cwd).resolve()
|
|
1110
|
+
config = load_config(cwd, args.config)
|
|
1111
|
+
language = args.language or config.get("language") or DEFAULT_LANGUAGE
|
|
1112
|
+
if args.working_tree:
|
|
1113
|
+
files = get_working_tree_changed_files(args.base, cwd)
|
|
1114
|
+
head = "WORKING_TREE"
|
|
1115
|
+
else:
|
|
1116
|
+
files = get_changed_files(args.base, args.head, cwd)
|
|
1117
|
+
head = args.head
|
|
1118
|
+
commits = get_commits(args.base, args.head, cwd)
|
|
1119
|
+
digest = render_digest(args.base, head, files, commits, language, config)
|
|
1120
|
+
if args.output:
|
|
1121
|
+
Path(args.output).write_text(digest, encoding="utf-8")
|
|
1122
|
+
else:
|
|
1123
|
+
print(digest)
|
|
1124
|
+
return 0
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def command_config_validate(args: argparse.Namespace) -> int:
|
|
1128
|
+
ok, errors = validate_config(Path(args.file))
|
|
1129
|
+
if ok:
|
|
1130
|
+
print("config ok")
|
|
1131
|
+
return 0
|
|
1132
|
+
for error in errors:
|
|
1133
|
+
print(error, file=sys.stderr)
|
|
1134
|
+
return 1
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def command_github_comment(args: argparse.Namespace) -> int:
|
|
1138
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
1139
|
+
repository = os.environ.get("GITHUB_REPOSITORY")
|
|
1140
|
+
event_path = os.environ.get("GITHUB_EVENT_PATH")
|
|
1141
|
+
if not token:
|
|
1142
|
+
raise RuntimeError("GITHUB_TOKEN is required")
|
|
1143
|
+
if not repository:
|
|
1144
|
+
raise RuntimeError("GITHUB_REPOSITORY is required")
|
|
1145
|
+
if not event_path:
|
|
1146
|
+
raise RuntimeError("GITHUB_EVENT_PATH is required")
|
|
1147
|
+
|
|
1148
|
+
digest = Path(args.file).read_text(encoding="utf-8")
|
|
1149
|
+
pull_number = load_pull_request_number(Path(event_path))
|
|
1150
|
+
body = build_pr_comment_body(digest, args.marker, args.language)
|
|
1151
|
+
result = upsert_pr_comment(
|
|
1152
|
+
repository=repository,
|
|
1153
|
+
pull_number=pull_number,
|
|
1154
|
+
token=token,
|
|
1155
|
+
body=body,
|
|
1156
|
+
marker=args.marker,
|
|
1157
|
+
api_url=args.api_url,
|
|
1158
|
+
)
|
|
1159
|
+
print(f"pull request comment {result}")
|
|
1160
|
+
return 0
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def command_manifest_validate(args: argparse.Namespace) -> int:
|
|
1164
|
+
ok, errors = validate_manifest(Path(args.file))
|
|
1165
|
+
if ok:
|
|
1166
|
+
print("manifest ok")
|
|
1167
|
+
return 0
|
|
1168
|
+
for error in errors:
|
|
1169
|
+
print(error, file=sys.stderr)
|
|
1170
|
+
return 1
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def command_integration_plan_validate(args: argparse.Namespace) -> int:
|
|
1174
|
+
ok, errors = validate_integration_plan(Path(args.file))
|
|
1175
|
+
if ok:
|
|
1176
|
+
print("integration plan ok")
|
|
1177
|
+
return 0
|
|
1178
|
+
for error in errors:
|
|
1179
|
+
print(error, file=sys.stderr)
|
|
1180
|
+
return 1
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def command_agents_check(args: argparse.Namespace) -> int:
|
|
1184
|
+
ok, errors = validate_agents_file(Path(args.file), args.max_lines)
|
|
1185
|
+
if ok:
|
|
1186
|
+
print("agents ok")
|
|
1187
|
+
return 0
|
|
1188
|
+
for error in errors:
|
|
1189
|
+
print(error, file=sys.stderr)
|
|
1190
|
+
return 1
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def command_heartbeat_new(args: argparse.Namespace) -> int:
|
|
1194
|
+
heartbeat = new_heartbeat(args.agent, args.task)
|
|
1195
|
+
text = json.dumps(heartbeat, indent=2, ensure_ascii=False) + "\n"
|
|
1196
|
+
if args.output:
|
|
1197
|
+
Path(args.output).write_text(text, encoding="utf-8")
|
|
1198
|
+
else:
|
|
1199
|
+
print(text, end="")
|
|
1200
|
+
return 0
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def command_manager_init(args: argparse.Namespace) -> int:
|
|
1204
|
+
cwd = Path(args.cwd).resolve()
|
|
1205
|
+
path = event_log_path(cwd, args.state_dir)
|
|
1206
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1207
|
+
path.touch(exist_ok=True)
|
|
1208
|
+
print(f"manager initialized: {path}")
|
|
1209
|
+
return 0
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def command_manager_event_append(args: argparse.Namespace) -> int:
|
|
1213
|
+
cwd = Path(args.cwd).resolve()
|
|
1214
|
+
payload: dict[str, Any] = {}
|
|
1215
|
+
if args.payload:
|
|
1216
|
+
try:
|
|
1217
|
+
payload = json.loads(Path(args.payload).read_text(encoding="utf-8"))
|
|
1218
|
+
except json.JSONDecodeError as exc:
|
|
1219
|
+
raise RuntimeError(f"invalid payload JSON: {exc}") from exc
|
|
1220
|
+
event = make_event(args.type, args.actor, args.subject, payload)
|
|
1221
|
+
append_event(event_log_path(cwd, args.state_dir), event)
|
|
1222
|
+
print(json.dumps(event, indent=2, ensure_ascii=False))
|
|
1223
|
+
return 0
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def command_manager_heartbeat(args: argparse.Namespace) -> int:
|
|
1227
|
+
cwd = Path(args.cwd).resolve()
|
|
1228
|
+
heartbeat = new_heartbeat(args.agent, args.task)
|
|
1229
|
+
event = make_event(
|
|
1230
|
+
"agent.heartbeat",
|
|
1231
|
+
args.agent,
|
|
1232
|
+
args.subject,
|
|
1233
|
+
{"heartbeat": heartbeat},
|
|
1234
|
+
)
|
|
1235
|
+
append_event(event_log_path(cwd, args.state_dir), event)
|
|
1236
|
+
print(json.dumps(event, indent=2, ensure_ascii=False))
|
|
1237
|
+
return 0
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def command_manager_lease_request(args: argparse.Namespace) -> int:
|
|
1241
|
+
cwd = Path(args.cwd).resolve()
|
|
1242
|
+
path = event_log_path(cwd, args.state_dir)
|
|
1243
|
+
events = load_events(path)
|
|
1244
|
+
lease = new_lease(args.holder, args.kind, args.pattern, args.task)
|
|
1245
|
+
|
|
1246
|
+
append_event(
|
|
1247
|
+
path,
|
|
1248
|
+
make_event(
|
|
1249
|
+
"lease.requested",
|
|
1250
|
+
args.holder,
|
|
1251
|
+
args.subject,
|
|
1252
|
+
{"lease": lease},
|
|
1253
|
+
),
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
conflicts = find_lease_conflicts(lease, active_leases(events))
|
|
1257
|
+
if conflicts:
|
|
1258
|
+
decision = new_decision(
|
|
1259
|
+
question=(
|
|
1260
|
+
f"Should {args.holder} proceed with {', '.join(args.pattern)} "
|
|
1261
|
+
"despite active lease overlap?"
|
|
1262
|
+
),
|
|
1263
|
+
context=json.dumps(
|
|
1264
|
+
{
|
|
1265
|
+
"requested_lease": lease,
|
|
1266
|
+
"conflicting_leases": conflicts,
|
|
1267
|
+
},
|
|
1268
|
+
ensure_ascii=False,
|
|
1269
|
+
sort_keys=True,
|
|
1270
|
+
),
|
|
1271
|
+
related_artifacts=[
|
|
1272
|
+
lease["id"],
|
|
1273
|
+
*[conflict["id"] for conflict in conflicts],
|
|
1274
|
+
],
|
|
1275
|
+
risk="medium",
|
|
1276
|
+
)
|
|
1277
|
+
append_event(
|
|
1278
|
+
path,
|
|
1279
|
+
make_event(
|
|
1280
|
+
"decision.requested",
|
|
1281
|
+
"manager",
|
|
1282
|
+
args.subject,
|
|
1283
|
+
{"decision": decision},
|
|
1284
|
+
),
|
|
1285
|
+
)
|
|
1286
|
+
print(f"conflict: {args.holder} overlaps active lease(s)")
|
|
1287
|
+
for conflict in conflicts:
|
|
1288
|
+
patterns = ", ".join(conflict.get("scope", {}).get("patterns", []))
|
|
1289
|
+
print(f"- {conflict['id']} held by {conflict['holder']}: {patterns}")
|
|
1290
|
+
print(f"decision requested: {decision['id']}")
|
|
1291
|
+
return 0
|
|
1292
|
+
|
|
1293
|
+
lease["status"] = "active"
|
|
1294
|
+
append_event(
|
|
1295
|
+
path,
|
|
1296
|
+
make_event(
|
|
1297
|
+
"lease.granted",
|
|
1298
|
+
"manager",
|
|
1299
|
+
args.subject,
|
|
1300
|
+
{"lease": lease},
|
|
1301
|
+
),
|
|
1302
|
+
)
|
|
1303
|
+
print(f"lease granted: {lease['id']}")
|
|
1304
|
+
return 0
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def command_manager_lease_release(args: argparse.Namespace) -> int:
|
|
1308
|
+
cwd = Path(args.cwd).resolve()
|
|
1309
|
+
path = event_log_path(cwd, args.state_dir)
|
|
1310
|
+
append_event(
|
|
1311
|
+
path,
|
|
1312
|
+
make_event(
|
|
1313
|
+
"lease.released",
|
|
1314
|
+
args.actor,
|
|
1315
|
+
args.subject,
|
|
1316
|
+
{"lease_id": args.id},
|
|
1317
|
+
),
|
|
1318
|
+
)
|
|
1319
|
+
print(f"lease released: {args.id}")
|
|
1320
|
+
return 0
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def command_manager_leases(args: argparse.Namespace) -> int:
|
|
1324
|
+
cwd = Path(args.cwd).resolve()
|
|
1325
|
+
leases = list(
|
|
1326
|
+
active_leases(load_events(event_log_path(cwd, args.state_dir))).values()
|
|
1327
|
+
)
|
|
1328
|
+
if args.json:
|
|
1329
|
+
print(json.dumps(leases, indent=2, ensure_ascii=False))
|
|
1330
|
+
return 0
|
|
1331
|
+
if not leases:
|
|
1332
|
+
print("no active leases")
|
|
1333
|
+
return 0
|
|
1334
|
+
for lease in leases:
|
|
1335
|
+
patterns = ", ".join(lease.get("scope", {}).get("patterns", []))
|
|
1336
|
+
print(
|
|
1337
|
+
f"{lease['id']} {lease['holder']} "
|
|
1338
|
+
f"{lease.get('scope', {}).get('kind')}: {patterns}"
|
|
1339
|
+
)
|
|
1340
|
+
return 0
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def command_manager_decisions(args: argparse.Namespace) -> int:
|
|
1344
|
+
cwd = Path(args.cwd).resolve()
|
|
1345
|
+
decisions = list(
|
|
1346
|
+
open_decisions(load_events(event_log_path(cwd, args.state_dir))).values()
|
|
1347
|
+
)
|
|
1348
|
+
if args.json:
|
|
1349
|
+
print(json.dumps(decisions, indent=2, ensure_ascii=False))
|
|
1350
|
+
return 0
|
|
1351
|
+
if not decisions:
|
|
1352
|
+
print("no open decisions")
|
|
1353
|
+
return 0
|
|
1354
|
+
for decision in decisions:
|
|
1355
|
+
print(
|
|
1356
|
+
f"{decision['id']} [{decision.get('risk', 'unknown')}] "
|
|
1357
|
+
f"{decision['question']}"
|
|
1358
|
+
)
|
|
1359
|
+
return 0
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
def command_manager_contract_propose(args: argparse.Namespace) -> int:
|
|
1363
|
+
cwd = Path(args.cwd).resolve()
|
|
1364
|
+
path = event_log_path(cwd, args.state_dir)
|
|
1365
|
+
change = new_contract_change(
|
|
1366
|
+
proposer=args.proposer,
|
|
1367
|
+
kind=args.kind,
|
|
1368
|
+
name=args.name,
|
|
1369
|
+
summary=args.summary,
|
|
1370
|
+
compatibility=args.compatibility,
|
|
1371
|
+
affected_artifacts=args.artifact,
|
|
1372
|
+
)
|
|
1373
|
+
append_event(
|
|
1374
|
+
path,
|
|
1375
|
+
make_event(
|
|
1376
|
+
"contract.change.proposed",
|
|
1377
|
+
args.proposer,
|
|
1378
|
+
args.subject,
|
|
1379
|
+
{"contract_change": change},
|
|
1380
|
+
),
|
|
1381
|
+
)
|
|
1382
|
+
print(f"contract change proposed: {change['id']}")
|
|
1383
|
+
|
|
1384
|
+
if args.compatibility == "breaking":
|
|
1385
|
+
decision = new_decision(
|
|
1386
|
+
question=f"Should breaking contract change proceed: {args.name}?",
|
|
1387
|
+
context=json.dumps(change, ensure_ascii=False, sort_keys=True),
|
|
1388
|
+
related_artifacts=[change["id"], *args.artifact],
|
|
1389
|
+
risk="high",
|
|
1390
|
+
)
|
|
1391
|
+
append_event(
|
|
1392
|
+
path,
|
|
1393
|
+
make_event(
|
|
1394
|
+
"decision.requested",
|
|
1395
|
+
"manager",
|
|
1396
|
+
args.subject,
|
|
1397
|
+
{"decision": decision},
|
|
1398
|
+
),
|
|
1399
|
+
)
|
|
1400
|
+
print(f"decision requested: {decision['id']}")
|
|
1401
|
+
return 0
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def command_manager_contracts(args: argparse.Namespace) -> int:
|
|
1405
|
+
cwd = Path(args.cwd).resolve()
|
|
1406
|
+
changes = list(
|
|
1407
|
+
contract_changes(load_events(event_log_path(cwd, args.state_dir))).values()
|
|
1408
|
+
)
|
|
1409
|
+
if args.kind:
|
|
1410
|
+
changes = [change for change in changes if change.get("kind") == args.kind]
|
|
1411
|
+
|
|
1412
|
+
if args.json:
|
|
1413
|
+
print(json.dumps(changes, indent=2, ensure_ascii=False))
|
|
1414
|
+
return 0
|
|
1415
|
+
if not changes:
|
|
1416
|
+
print("no contract changes")
|
|
1417
|
+
return 0
|
|
1418
|
+
for change in changes:
|
|
1419
|
+
print(
|
|
1420
|
+
f"{change['id']} [{change.get('compatibility', 'unknown')}] "
|
|
1421
|
+
f"{change.get('kind')}: {change.get('name')}"
|
|
1422
|
+
)
|
|
1423
|
+
return 0
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
def command_manager_decision_record(args: argparse.Namespace) -> int:
|
|
1427
|
+
cwd = Path(args.cwd).resolve()
|
|
1428
|
+
path = event_log_path(cwd, args.state_dir)
|
|
1429
|
+
events = load_events(path)
|
|
1430
|
+
decisions = open_decisions(events)
|
|
1431
|
+
if args.id not in decisions:
|
|
1432
|
+
raise RuntimeError(f"open decision not found: {args.id}")
|
|
1433
|
+
|
|
1434
|
+
payload = {
|
|
1435
|
+
"decision_id": args.id,
|
|
1436
|
+
"decision": args.decision,
|
|
1437
|
+
"decider": args.decider,
|
|
1438
|
+
"status": args.status,
|
|
1439
|
+
"note": args.note,
|
|
1440
|
+
"recorded_at": utc_now(),
|
|
1441
|
+
}
|
|
1442
|
+
append_event(
|
|
1443
|
+
path,
|
|
1444
|
+
make_event(
|
|
1445
|
+
"decision.recorded",
|
|
1446
|
+
args.decider,
|
|
1447
|
+
args.subject,
|
|
1448
|
+
payload,
|
|
1449
|
+
),
|
|
1450
|
+
)
|
|
1451
|
+
print(f"decision recorded: {args.id} [{args.status}]")
|
|
1452
|
+
return 0
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def command_manager_status(args: argparse.Namespace) -> int:
|
|
1456
|
+
cwd = Path(args.cwd).resolve()
|
|
1457
|
+
events = load_events(event_log_path(cwd, args.state_dir))
|
|
1458
|
+
leases = list(active_leases(events).values())
|
|
1459
|
+
decisions = list(open_decisions(events).values())
|
|
1460
|
+
heartbeats = latest_heartbeats(events)
|
|
1461
|
+
status = {
|
|
1462
|
+
"event_count": len(events),
|
|
1463
|
+
"active_leases": leases,
|
|
1464
|
+
"open_decisions": decisions,
|
|
1465
|
+
"contract_changes": list(contract_changes(events).values()),
|
|
1466
|
+
"latest_heartbeats": heartbeats,
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
if args.json:
|
|
1470
|
+
print(json.dumps(status, indent=2, ensure_ascii=False))
|
|
1471
|
+
return 0
|
|
1472
|
+
|
|
1473
|
+
print(f"events: {len(events)}")
|
|
1474
|
+
print(f"active leases: {len(leases)}")
|
|
1475
|
+
print(f"open decisions: {len(decisions)}")
|
|
1476
|
+
print(f"contract changes: {len(status['contract_changes'])}")
|
|
1477
|
+
print(f"latest heartbeats: {len(heartbeats)}")
|
|
1478
|
+
for agent, heartbeat in sorted(heartbeats.items()):
|
|
1479
|
+
task = heartbeat.get("task", "")
|
|
1480
|
+
state = heartbeat.get("status", "unknown")
|
|
1481
|
+
print(f"- {agent}: {state} {task}".rstrip())
|
|
1482
|
+
return 0
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def command_manager_forecast(args: argparse.Namespace) -> int:
|
|
1486
|
+
cwd = Path(args.cwd).resolve()
|
|
1487
|
+
events = load_events(event_log_path(cwd, args.state_dir))
|
|
1488
|
+
config = load_config(cwd, args.config)
|
|
1489
|
+
|
|
1490
|
+
changed_files: list[str] = list(args.changed_file or [])
|
|
1491
|
+
if args.base:
|
|
1492
|
+
head = args.head or "HEAD"
|
|
1493
|
+
changed_files.extend(
|
|
1494
|
+
entry["path"] for entry in get_changed_files(args.base, head, cwd)
|
|
1495
|
+
)
|
|
1496
|
+
|
|
1497
|
+
report = forecast_report(events, config, changed_files or None)
|
|
1498
|
+
|
|
1499
|
+
if args.json:
|
|
1500
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
1501
|
+
else:
|
|
1502
|
+
print(f"risk level: {report['risk_level']}")
|
|
1503
|
+
print(f"active leases: {report['active_lease_count']}")
|
|
1504
|
+
|
|
1505
|
+
conflicts = report["path_conflicts"]
|
|
1506
|
+
print(f"path conflicts: {len(conflicts)}")
|
|
1507
|
+
for item in conflicts:
|
|
1508
|
+
print(
|
|
1509
|
+
f"- [{item['severity']}] {item['left_holder']} "
|
|
1510
|
+
f"({', '.join(item['left_patterns'])}) overlaps "
|
|
1511
|
+
f"{item['right_holder']} ({', '.join(item['right_patterns'])})"
|
|
1512
|
+
)
|
|
1513
|
+
|
|
1514
|
+
pressure = report["contract_pressure"]
|
|
1515
|
+
print(f"contract pressure: {len(pressure)}")
|
|
1516
|
+
for item in pressure:
|
|
1517
|
+
print(
|
|
1518
|
+
f"- [{item['severity']}] {item['kind']}: {item['name']} "
|
|
1519
|
+
f"({item['compatibility']}) by {item['proposer']}"
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
protected = report["protected_pressure"]
|
|
1523
|
+
print(f"protected-path pressure: {len(protected)}")
|
|
1524
|
+
for item in protected:
|
|
1525
|
+
print(
|
|
1526
|
+
f"- [{item['risk']}] {item['holder']} lease {item['lease_pattern']} "
|
|
1527
|
+
f"overlaps protected {item['protected_pattern']}"
|
|
1528
|
+
)
|
|
1529
|
+
|
|
1530
|
+
findings = report["changed_file_findings"]
|
|
1531
|
+
print(f"changed-file findings: {len(findings)}")
|
|
1532
|
+
for item in findings:
|
|
1533
|
+
holders = ", ".join(
|
|
1534
|
+
str(entry["holder"]) for entry in item["overlapping_leases"]
|
|
1535
|
+
)
|
|
1536
|
+
flags = []
|
|
1537
|
+
if holders:
|
|
1538
|
+
flags.append(f"leases: {holders}")
|
|
1539
|
+
if item["protected_matches"]:
|
|
1540
|
+
flags.append("protected")
|
|
1541
|
+
print(f"- {item['path']} ({'; '.join(flags)})")
|
|
1542
|
+
|
|
1543
|
+
if args.fail_on_conflict and report["risk_level"] in ("high", "critical"):
|
|
1544
|
+
return 1
|
|
1545
|
+
return 0
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def command_mcp_serve(args: argparse.Namespace) -> int:
|
|
1549
|
+
from . import mcp_server
|
|
1550
|
+
|
|
1551
|
+
return mcp_server.serve(Path(args.cwd).resolve(), args.state_dir)
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1555
|
+
parser = argparse.ArgumentParser(
|
|
1556
|
+
prog="coprogrammer",
|
|
1557
|
+
description="Protocol tooling for semantic branch integration.",
|
|
1558
|
+
)
|
|
1559
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
1560
|
+
|
|
1561
|
+
digest = subparsers.add_parser("digest", help="Generate a branch digest draft.")
|
|
1562
|
+
digest.add_argument("--base", default="origin/main")
|
|
1563
|
+
digest.add_argument("--head", default="HEAD")
|
|
1564
|
+
digest.add_argument("--cwd", default=".")
|
|
1565
|
+
digest.add_argument("--output")
|
|
1566
|
+
digest.add_argument(
|
|
1567
|
+
"--config",
|
|
1568
|
+
default=DEFAULT_CONFIG_FILE,
|
|
1569
|
+
help=f"Project policy config path. Default: {DEFAULT_CONFIG_FILE}.",
|
|
1570
|
+
)
|
|
1571
|
+
digest.add_argument(
|
|
1572
|
+
"--language",
|
|
1573
|
+
default=os.environ.get("COPROGRAMMER_LANGUAGE"),
|
|
1574
|
+
help="Output language: en or zh-CN. Overrides config and COPROGRAMMER_LANGUAGE.",
|
|
1575
|
+
)
|
|
1576
|
+
digest.add_argument(
|
|
1577
|
+
"--working-tree",
|
|
1578
|
+
action="store_true",
|
|
1579
|
+
help="Digest uncommitted working-tree changes, including untracked files.",
|
|
1580
|
+
)
|
|
1581
|
+
digest.set_defaults(func=command_digest)
|
|
1582
|
+
|
|
1583
|
+
github_comment = subparsers.add_parser(
|
|
1584
|
+
"github-comment",
|
|
1585
|
+
help="Create or update a GitHub PR comment with a digest file.",
|
|
1586
|
+
)
|
|
1587
|
+
github_comment.add_argument("--file", required=True)
|
|
1588
|
+
github_comment.add_argument("--marker", default=DEFAULT_COMMENT_MARKER)
|
|
1589
|
+
github_comment.add_argument(
|
|
1590
|
+
"--language",
|
|
1591
|
+
default=os.environ.get("COPROGRAMMER_LANGUAGE", DEFAULT_LANGUAGE),
|
|
1592
|
+
help="Comment language: en or zh-CN. Can also use COPROGRAMMER_LANGUAGE.",
|
|
1593
|
+
)
|
|
1594
|
+
github_comment.add_argument(
|
|
1595
|
+
"--api-url",
|
|
1596
|
+
default=os.environ.get("GITHUB_API_URL", DEFAULT_GITHUB_API_URL),
|
|
1597
|
+
)
|
|
1598
|
+
github_comment.set_defaults(func=command_github_comment)
|
|
1599
|
+
|
|
1600
|
+
config = subparsers.add_parser("config", help="Work with CoProgrammer config.")
|
|
1601
|
+
config_subparsers = config.add_subparsers(dest="config_command", required=True)
|
|
1602
|
+
config_validate = config_subparsers.add_parser("validate", help="Validate config JSON.")
|
|
1603
|
+
config_validate.add_argument("file", nargs="?", default=DEFAULT_CONFIG_FILE)
|
|
1604
|
+
config_validate.set_defaults(func=command_config_validate)
|
|
1605
|
+
|
|
1606
|
+
manifest = subparsers.add_parser("manifest", help="Work with change manifests.")
|
|
1607
|
+
manifest_subparsers = manifest.add_subparsers(dest="manifest_command", required=True)
|
|
1608
|
+
validate = manifest_subparsers.add_parser("validate", help="Validate a manifest.")
|
|
1609
|
+
validate.add_argument("file")
|
|
1610
|
+
validate.set_defaults(func=command_manifest_validate)
|
|
1611
|
+
|
|
1612
|
+
integration_plan = subparsers.add_parser(
|
|
1613
|
+
"integration-plan",
|
|
1614
|
+
help="Work with integration plan artifacts.",
|
|
1615
|
+
)
|
|
1616
|
+
integration_plan_subparsers = integration_plan.add_subparsers(
|
|
1617
|
+
dest="integration_plan_command",
|
|
1618
|
+
required=True,
|
|
1619
|
+
)
|
|
1620
|
+
integration_plan_validate = integration_plan_subparsers.add_parser(
|
|
1621
|
+
"validate",
|
|
1622
|
+
help="Validate an integration plan JSON artifact.",
|
|
1623
|
+
)
|
|
1624
|
+
integration_plan_validate.add_argument("file")
|
|
1625
|
+
integration_plan_validate.set_defaults(func=command_integration_plan_validate)
|
|
1626
|
+
|
|
1627
|
+
agents = subparsers.add_parser(
|
|
1628
|
+
"agents",
|
|
1629
|
+
help="Work with agent instruction files.",
|
|
1630
|
+
)
|
|
1631
|
+
agents_subparsers = agents.add_subparsers(dest="agents_command", required=True)
|
|
1632
|
+
agents_check = agents_subparsers.add_parser(
|
|
1633
|
+
"check",
|
|
1634
|
+
help="Check that AGENTS.md stays minimal and static.",
|
|
1635
|
+
)
|
|
1636
|
+
agents_check.add_argument("file", nargs="?", default="AGENTS.md")
|
|
1637
|
+
agents_check.add_argument("--max-lines", type=int, default=120)
|
|
1638
|
+
agents_check.set_defaults(func=command_agents_check)
|
|
1639
|
+
|
|
1640
|
+
heartbeat = subparsers.add_parser("heartbeat", help="Work with agent heartbeats.")
|
|
1641
|
+
heartbeat_subparsers = heartbeat.add_subparsers(dest="heartbeat_command", required=True)
|
|
1642
|
+
heartbeat_new = heartbeat_subparsers.add_parser("new", help="Create a heartbeat JSON.")
|
|
1643
|
+
heartbeat_new.add_argument("--agent", required=True)
|
|
1644
|
+
heartbeat_new.add_argument("--task", required=True)
|
|
1645
|
+
heartbeat_new.add_argument("--output")
|
|
1646
|
+
heartbeat_new.set_defaults(func=command_heartbeat_new)
|
|
1647
|
+
|
|
1648
|
+
manager = subparsers.add_parser(
|
|
1649
|
+
"manager",
|
|
1650
|
+
help="Work with the local Manager Plane prototype.",
|
|
1651
|
+
)
|
|
1652
|
+
manager_subparsers = manager.add_subparsers(dest="manager_command", required=True)
|
|
1653
|
+
|
|
1654
|
+
manager_init = manager_subparsers.add_parser(
|
|
1655
|
+
"init",
|
|
1656
|
+
help="Initialize local Manager event log.",
|
|
1657
|
+
)
|
|
1658
|
+
manager_init.add_argument("--cwd", default=".")
|
|
1659
|
+
manager_init.add_argument("--state-dir", default=DEFAULT_MANAGER_DIR)
|
|
1660
|
+
manager_init.set_defaults(func=command_manager_init)
|
|
1661
|
+
|
|
1662
|
+
manager_status = manager_subparsers.add_parser(
|
|
1663
|
+
"status",
|
|
1664
|
+
help="Show reconstructed Manager state.",
|
|
1665
|
+
)
|
|
1666
|
+
manager_status.add_argument("--cwd", default=".")
|
|
1667
|
+
manager_status.add_argument("--state-dir", default=DEFAULT_MANAGER_DIR)
|
|
1668
|
+
manager_status.add_argument("--json", action="store_true")
|
|
1669
|
+
manager_status.set_defaults(func=command_manager_status)
|
|
1670
|
+
|
|
1671
|
+
manager_heartbeat = manager_subparsers.add_parser(
|
|
1672
|
+
"heartbeat",
|
|
1673
|
+
help="Append an agent heartbeat event.",
|
|
1674
|
+
)
|
|
1675
|
+
manager_heartbeat.add_argument("--cwd", default=".")
|
|
1676
|
+
manager_heartbeat.add_argument("--state-dir", default=DEFAULT_MANAGER_DIR)
|
|
1677
|
+
manager_heartbeat.add_argument("--subject", default="repo:local")
|
|
1678
|
+
manager_heartbeat.add_argument("--agent", required=True)
|
|
1679
|
+
manager_heartbeat.add_argument("--task", required=True)
|
|
1680
|
+
manager_heartbeat.set_defaults(func=command_manager_heartbeat)
|
|
1681
|
+
|
|
1682
|
+
manager_event = manager_subparsers.add_parser(
|
|
1683
|
+
"event",
|
|
1684
|
+
help="Work with raw manager events.",
|
|
1685
|
+
)
|
|
1686
|
+
manager_event_subparsers = manager_event.add_subparsers(
|
|
1687
|
+
dest="event_command",
|
|
1688
|
+
required=True,
|
|
1689
|
+
)
|
|
1690
|
+
manager_event_append = manager_event_subparsers.add_parser(
|
|
1691
|
+
"append",
|
|
1692
|
+
help="Append a raw manager event.",
|
|
1693
|
+
)
|
|
1694
|
+
manager_event_append.add_argument("--cwd", default=".")
|
|
1695
|
+
manager_event_append.add_argument("--state-dir", default=DEFAULT_MANAGER_DIR)
|
|
1696
|
+
manager_event_append.add_argument("--type", required=True, choices=EVENT_TYPES)
|
|
1697
|
+
manager_event_append.add_argument("--actor", required=True)
|
|
1698
|
+
manager_event_append.add_argument("--subject", default="repo:local")
|
|
1699
|
+
manager_event_append.add_argument("--payload")
|
|
1700
|
+
manager_event_append.set_defaults(func=command_manager_event_append)
|
|
1701
|
+
|
|
1702
|
+
manager_lease = manager_subparsers.add_parser(
|
|
1703
|
+
"lease",
|
|
1704
|
+
help="Work with workspace leases.",
|
|
1705
|
+
)
|
|
1706
|
+
manager_lease_subparsers = manager_lease.add_subparsers(
|
|
1707
|
+
dest="lease_command",
|
|
1708
|
+
required=True,
|
|
1709
|
+
)
|
|
1710
|
+
manager_lease_request = manager_lease_subparsers.add_parser(
|
|
1711
|
+
"request",
|
|
1712
|
+
help="Request a workspace lease.",
|
|
1713
|
+
)
|
|
1714
|
+
manager_lease_request.add_argument("--cwd", default=".")
|
|
1715
|
+
manager_lease_request.add_argument("--state-dir", default=DEFAULT_MANAGER_DIR)
|
|
1716
|
+
manager_lease_request.add_argument("--subject", default="repo:local")
|
|
1717
|
+
manager_lease_request.add_argument("--holder", required=True)
|
|
1718
|
+
manager_lease_request.add_argument("--kind", default="path", choices=LEASE_KINDS)
|
|
1719
|
+
manager_lease_request.add_argument("--pattern", action="append", required=True)
|
|
1720
|
+
manager_lease_request.add_argument("--task", default="")
|
|
1721
|
+
manager_lease_request.set_defaults(func=command_manager_lease_request)
|
|
1722
|
+
|
|
1723
|
+
manager_lease_release = manager_lease_subparsers.add_parser(
|
|
1724
|
+
"release",
|
|
1725
|
+
help="Release a workspace lease.",
|
|
1726
|
+
)
|
|
1727
|
+
manager_lease_release.add_argument("--cwd", default=".")
|
|
1728
|
+
manager_lease_release.add_argument("--state-dir", default=DEFAULT_MANAGER_DIR)
|
|
1729
|
+
manager_lease_release.add_argument("--subject", default="repo:local")
|
|
1730
|
+
manager_lease_release.add_argument("--actor", required=True)
|
|
1731
|
+
manager_lease_release.add_argument("--id", required=True)
|
|
1732
|
+
manager_lease_release.set_defaults(func=command_manager_lease_release)
|
|
1733
|
+
|
|
1734
|
+
manager_leases = manager_subparsers.add_parser(
|
|
1735
|
+
"leases",
|
|
1736
|
+
help="List active workspace leases.",
|
|
1737
|
+
)
|
|
1738
|
+
manager_leases.add_argument("--cwd", default=".")
|
|
1739
|
+
manager_leases.add_argument("--state-dir", default=DEFAULT_MANAGER_DIR)
|
|
1740
|
+
manager_leases.add_argument("--json", action="store_true")
|
|
1741
|
+
manager_leases.set_defaults(func=command_manager_leases)
|
|
1742
|
+
|
|
1743
|
+
manager_decisions = manager_subparsers.add_parser(
|
|
1744
|
+
"decisions",
|
|
1745
|
+
help="List open decision records.",
|
|
1746
|
+
)
|
|
1747
|
+
manager_decisions.add_argument("--cwd", default=".")
|
|
1748
|
+
manager_decisions.add_argument("--state-dir", default=DEFAULT_MANAGER_DIR)
|
|
1749
|
+
manager_decisions.add_argument("--json", action="store_true")
|
|
1750
|
+
manager_decisions.set_defaults(func=command_manager_decisions)
|
|
1751
|
+
|
|
1752
|
+
manager_contract = manager_subparsers.add_parser(
|
|
1753
|
+
"contract",
|
|
1754
|
+
help="Work with shared contract changes.",
|
|
1755
|
+
)
|
|
1756
|
+
manager_contract_subparsers = manager_contract.add_subparsers(
|
|
1757
|
+
dest="contract_command",
|
|
1758
|
+
required=True,
|
|
1759
|
+
)
|
|
1760
|
+
manager_contract_propose = manager_contract_subparsers.add_parser(
|
|
1761
|
+
"propose",
|
|
1762
|
+
help="Propose a shared contract change.",
|
|
1763
|
+
)
|
|
1764
|
+
manager_contract_propose.add_argument("--cwd", default=".")
|
|
1765
|
+
manager_contract_propose.add_argument("--state-dir", default=DEFAULT_MANAGER_DIR)
|
|
1766
|
+
manager_contract_propose.add_argument("--subject", default="repo:local")
|
|
1767
|
+
manager_contract_propose.add_argument("--proposer", required=True)
|
|
1768
|
+
manager_contract_propose.add_argument(
|
|
1769
|
+
"--kind",
|
|
1770
|
+
required=True,
|
|
1771
|
+
choices=CONTRACT_KINDS,
|
|
1772
|
+
)
|
|
1773
|
+
manager_contract_propose.add_argument("--name", required=True)
|
|
1774
|
+
manager_contract_propose.add_argument("--summary", required=True)
|
|
1775
|
+
manager_contract_propose.add_argument(
|
|
1776
|
+
"--compatibility",
|
|
1777
|
+
default="unknown",
|
|
1778
|
+
choices=CONTRACT_COMPATIBILITY,
|
|
1779
|
+
)
|
|
1780
|
+
manager_contract_propose.add_argument("--artifact", action="append", default=[])
|
|
1781
|
+
manager_contract_propose.set_defaults(func=command_manager_contract_propose)
|
|
1782
|
+
|
|
1783
|
+
manager_contracts = manager_subparsers.add_parser(
|
|
1784
|
+
"contracts",
|
|
1785
|
+
help="List proposed shared contract changes.",
|
|
1786
|
+
)
|
|
1787
|
+
manager_contracts.add_argument("--cwd", default=".")
|
|
1788
|
+
manager_contracts.add_argument("--state-dir", default=DEFAULT_MANAGER_DIR)
|
|
1789
|
+
manager_contracts.add_argument("--kind", choices=CONTRACT_KINDS)
|
|
1790
|
+
manager_contracts.add_argument("--json", action="store_true")
|
|
1791
|
+
manager_contracts.set_defaults(func=command_manager_contracts)
|
|
1792
|
+
|
|
1793
|
+
mcp = subparsers.add_parser(
|
|
1794
|
+
"mcp",
|
|
1795
|
+
help="Model Context Protocol server for MCP clients.",
|
|
1796
|
+
)
|
|
1797
|
+
mcp_subparsers = mcp.add_subparsers(dest="mcp_command", required=True)
|
|
1798
|
+
mcp_serve = mcp_subparsers.add_parser(
|
|
1799
|
+
"serve",
|
|
1800
|
+
help="Serve CoProgrammer tools over stdio (newline-delimited JSON-RPC).",
|
|
1801
|
+
)
|
|
1802
|
+
mcp_serve.add_argument("--cwd", default=".")
|
|
1803
|
+
mcp_serve.add_argument("--state-dir", default=DEFAULT_MANAGER_DIR)
|
|
1804
|
+
mcp_serve.set_defaults(func=command_mcp_serve)
|
|
1805
|
+
|
|
1806
|
+
manager_forecast = manager_subparsers.add_parser(
|
|
1807
|
+
"forecast",
|
|
1808
|
+
help="Forecast path, contract, and protected-path conflicts before PR time.",
|
|
1809
|
+
)
|
|
1810
|
+
manager_forecast.add_argument("--cwd", default=".")
|
|
1811
|
+
manager_forecast.add_argument("--state-dir", default=DEFAULT_MANAGER_DIR)
|
|
1812
|
+
manager_forecast.add_argument(
|
|
1813
|
+
"--config",
|
|
1814
|
+
default=DEFAULT_CONFIG_FILE,
|
|
1815
|
+
help=f"Project policy config path. Default: {DEFAULT_CONFIG_FILE}.",
|
|
1816
|
+
)
|
|
1817
|
+
manager_forecast.add_argument(
|
|
1818
|
+
"--changed-file",
|
|
1819
|
+
action="append",
|
|
1820
|
+
help="Explicit changed file to check against leases and protected paths.",
|
|
1821
|
+
)
|
|
1822
|
+
manager_forecast.add_argument(
|
|
1823
|
+
"--base",
|
|
1824
|
+
help="Optional git base ref; changed files vs --head are added to the check.",
|
|
1825
|
+
)
|
|
1826
|
+
manager_forecast.add_argument("--head", default="HEAD")
|
|
1827
|
+
manager_forecast.add_argument("--json", action="store_true")
|
|
1828
|
+
manager_forecast.add_argument(
|
|
1829
|
+
"--fail-on-conflict",
|
|
1830
|
+
action="store_true",
|
|
1831
|
+
help="Exit 1 when forecast risk level is high or critical.",
|
|
1832
|
+
)
|
|
1833
|
+
manager_forecast.set_defaults(func=command_manager_forecast)
|
|
1834
|
+
|
|
1835
|
+
manager_decision = manager_subparsers.add_parser(
|
|
1836
|
+
"decision",
|
|
1837
|
+
help="Work with a single decision record.",
|
|
1838
|
+
)
|
|
1839
|
+
manager_decision_subparsers = manager_decision.add_subparsers(
|
|
1840
|
+
dest="decision_command",
|
|
1841
|
+
required=True,
|
|
1842
|
+
)
|
|
1843
|
+
manager_decision_record = manager_decision_subparsers.add_parser(
|
|
1844
|
+
"record",
|
|
1845
|
+
help="Record the outcome for an open decision.",
|
|
1846
|
+
)
|
|
1847
|
+
manager_decision_record.add_argument("--cwd", default=".")
|
|
1848
|
+
manager_decision_record.add_argument("--state-dir", default=DEFAULT_MANAGER_DIR)
|
|
1849
|
+
manager_decision_record.add_argument("--subject", default="repo:local")
|
|
1850
|
+
manager_decision_record.add_argument("--id", required=True)
|
|
1851
|
+
manager_decision_record.add_argument("--decision", required=True)
|
|
1852
|
+
manager_decision_record.add_argument("--decider", required=True)
|
|
1853
|
+
manager_decision_record.add_argument(
|
|
1854
|
+
"--status",
|
|
1855
|
+
default="decided",
|
|
1856
|
+
choices=DECISION_RECORD_STATUSES,
|
|
1857
|
+
)
|
|
1858
|
+
manager_decision_record.add_argument("--note", default="")
|
|
1859
|
+
manager_decision_record.set_defaults(func=command_manager_decision_record)
|
|
1860
|
+
|
|
1861
|
+
return parser
|
|
1862
|
+
|
|
1863
|
+
|
|
1864
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1865
|
+
parser = build_parser()
|
|
1866
|
+
args = parser.parse_args(argv)
|
|
1867
|
+
try:
|
|
1868
|
+
return args.func(args)
|
|
1869
|
+
except RuntimeError as exc:
|
|
1870
|
+
print(str(exc), file=sys.stderr)
|
|
1871
|
+
return 2
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
if __name__ == "__main__":
|
|
1875
|
+
raise SystemExit(main())
|