agent-project-sdlc 0.1.19 → 0.1.21
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.
- package/README.md +9 -7
- package/assets/agents/AGENTS_CORE.md +2 -2
- package/assets/docs/README.md +10 -8
- package/assets/skills/pjsdlc_architect_design/SKILL.md +2 -0
- package/assets/skills/pjsdlc_dev_sprint/SKILL.md +7 -5
- package/assets/skills/pjsdlc_implementation_doc/SKILL.md +2 -2
- package/assets/skills/pjsdlc_manager/SKILL.md +4 -4
- package/assets/skills/pjsdlc_pm_prd/SKILL.md +3 -3
- package/assets/skills/pjsdlc_release_manager/SKILL.md +2 -0
- package/assets/skills/pjsdlc_reviewer/SKILL.md +2 -0
- package/assets/skills/pjsdlc_rfc_recalibrate/SKILL.md +2 -0
- package/assets/skills/pjsdlc_tester/SKILL.md +2 -2
- package/assets/templates/IMPLEMENTATION_DOC_TEMPLATE.md +1 -1
- package/assets/templates/PLAN_TEMPLATE.yaml +9 -6
- package/assets/tools/build_doc_overviews.py +152 -0
- package/assets/tools/harness_utils.py +858 -0
- package/assets/tools/impact_analyzer.py +51 -0
- package/assets/tools/run_current_gate.py +29 -0
- package/assets/tools/status.py +29 -0
- package/assets/tools/transition.py +68 -0
- package/assets/tools/validate_allowed_paths.py +44 -0
- package/assets/tools/validate_design.py +199 -0
- package/assets/tools/validate_dev_state.py +20 -0
- package/assets/tools/validate_harness.py +60 -0
- package/assets/tools/validate_plan.py +24 -0
- package/assets/tools/validate_plan_draft.py +19 -0
- package/assets/tools/validate_prd.py +27 -0
- package/assets/tools/validate_prompt_language.py +138 -0
- package/assets/tools/validate_release_plan.py +37 -0
- package/assets/tools/validate_review.py +59 -0
- package/assets/tools/validate_rfc.py +105 -0
- package/assets/tools/validate_task_docs.py +40 -0
- package/assets/tools/validate_test_plan.py +82 -0
- package/dist/lib/config.js +1 -0
- package/dist/lib/migrations.js +3 -0
- package/dist/lib/sync-engine.js +4 -0
- package/dist/lib/validators.js +227 -17
- package/package.json +1 -1
- package/source-mappings.yaml +6 -0
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import fnmatch
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
15
|
+
|
|
16
|
+
TASK_STATUSES = {
|
|
17
|
+
"pending",
|
|
18
|
+
"in_progress",
|
|
19
|
+
"done",
|
|
20
|
+
"blocked",
|
|
21
|
+
"pending_revision",
|
|
22
|
+
"cancelled",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
OPEN_TASK_STATUSES = {"pending", "in_progress", "blocked", "pending_revision"}
|
|
26
|
+
PARALLEL_MODES = {"runtime_managed", "user_orchestrated"}
|
|
27
|
+
PARALLEL_TRIGGERS = {"user_requested", "workflow_default"}
|
|
28
|
+
PARALLEL_RUNTIME_PROVIDERS = {"codex_native_subagents", "user_orchestrated", "codex_exec_worktree"}
|
|
29
|
+
PARALLEL_ALLOWED_PHASES = {
|
|
30
|
+
"REQUIREMENT_GATHERING",
|
|
31
|
+
"ARCHITECTING",
|
|
32
|
+
"SPRINTING",
|
|
33
|
+
"REVIEWING",
|
|
34
|
+
"TESTING",
|
|
35
|
+
"RELEASING",
|
|
36
|
+
"RFC_RECALIBRATION",
|
|
37
|
+
}
|
|
38
|
+
PARALLEL_READ_ONLY_PHASES = {"REQUIREMENT_GATHERING", "ARCHITECTING", "REVIEWING", "RELEASING", "RFC_RECALIBRATION"}
|
|
39
|
+
PARALLEL_PROTECTED_WRITE_PATTERNS = {
|
|
40
|
+
".codex/state/**",
|
|
41
|
+
"<harnessRoot>/state/**",
|
|
42
|
+
".docs/INDEX.md",
|
|
43
|
+
".docs/**/overview.md",
|
|
44
|
+
".docs/04_implementation/**",
|
|
45
|
+
".docs/06_review/**",
|
|
46
|
+
".docs/08_release/**",
|
|
47
|
+
}
|
|
48
|
+
TASK_ID_PATTERN = re.compile(r"^[A-Z]+-(\d+)$")
|
|
49
|
+
TASK_PHASES = {
|
|
50
|
+
"REQUIREMENT_GATHERING",
|
|
51
|
+
"ARCHITECTING",
|
|
52
|
+
"SPRINTING",
|
|
53
|
+
"REVIEWING",
|
|
54
|
+
"TESTING",
|
|
55
|
+
"RELEASING",
|
|
56
|
+
"RFC_RECALIBRATION",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class HarnessError(RuntimeError):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def repo_path(relative: str | Path) -> Path:
|
|
65
|
+
return ROOT / relative
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def read_text(relative: str | Path) -> str:
|
|
69
|
+
path = repo_path(relative)
|
|
70
|
+
if not path.exists():
|
|
71
|
+
raise HarnessError(f"Missing required file: {relative}")
|
|
72
|
+
return path.read_text(encoding="utf-8")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_yaml(relative: str | Path) -> Any:
|
|
76
|
+
path = repo_path(relative)
|
|
77
|
+
if not path.exists():
|
|
78
|
+
raise HarnessError(f"Missing required YAML file: {relative}")
|
|
79
|
+
text = path.read_text(encoding="utf-8")
|
|
80
|
+
if not text.strip():
|
|
81
|
+
return {}
|
|
82
|
+
try:
|
|
83
|
+
import yaml # type: ignore
|
|
84
|
+
|
|
85
|
+
data = yaml.safe_load(text)
|
|
86
|
+
return {} if data is None else data
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
try:
|
|
90
|
+
return json.loads(text)
|
|
91
|
+
except Exception:
|
|
92
|
+
return parse_simple_yaml(text)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_yaml_text(text: str) -> Any:
|
|
96
|
+
if not text.strip():
|
|
97
|
+
return {}
|
|
98
|
+
try:
|
|
99
|
+
import yaml # type: ignore
|
|
100
|
+
|
|
101
|
+
data = yaml.safe_load(text)
|
|
102
|
+
return {} if data is None else data
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
try:
|
|
106
|
+
return json.loads(text)
|
|
107
|
+
except Exception:
|
|
108
|
+
return parse_simple_yaml(text)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def dump_yaml(data: Any, relative: str | Path) -> None:
|
|
112
|
+
path = repo_path(relative)
|
|
113
|
+
path.write_text(to_simple_yaml(data).rstrip() + "\n", encoding="utf-8")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_simple_yaml(text: str) -> Any:
|
|
117
|
+
lines: list[tuple[int, str, int]] = []
|
|
118
|
+
for lineno, raw in enumerate(text.splitlines(), start=1):
|
|
119
|
+
if not raw.strip() or raw.lstrip().startswith("#"):
|
|
120
|
+
continue
|
|
121
|
+
indent = len(raw) - len(raw.lstrip(" "))
|
|
122
|
+
lines.append((indent, raw.strip(), lineno))
|
|
123
|
+
|
|
124
|
+
if not lines:
|
|
125
|
+
return {}
|
|
126
|
+
|
|
127
|
+
data, index = _parse_block(lines, 0, lines[0][0])
|
|
128
|
+
if index != len(lines):
|
|
129
|
+
_, stripped, lineno = lines[index]
|
|
130
|
+
raise HarnessError(f"Could not parse YAML near line {lineno}: {stripped}")
|
|
131
|
+
return data
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_block(lines: list[tuple[int, str, int]], index: int, indent: int) -> tuple[Any, int]:
|
|
135
|
+
if index >= len(lines):
|
|
136
|
+
return {}, index
|
|
137
|
+
current_indent, stripped, _ = lines[index]
|
|
138
|
+
if current_indent < indent:
|
|
139
|
+
return {}, index
|
|
140
|
+
if stripped.startswith("-"):
|
|
141
|
+
return _parse_list(lines, index, current_indent)
|
|
142
|
+
return _parse_dict(lines, index, current_indent)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse_dict(lines: list[tuple[int, str, int]], index: int, indent: int) -> tuple[dict[str, Any], int]:
|
|
146
|
+
result: dict[str, Any] = {}
|
|
147
|
+
while index < len(lines):
|
|
148
|
+
current_indent, stripped, lineno = lines[index]
|
|
149
|
+
if current_indent < indent:
|
|
150
|
+
break
|
|
151
|
+
if current_indent > indent:
|
|
152
|
+
raise HarnessError(f"Unexpected indentation near line {lineno}: {stripped}")
|
|
153
|
+
if stripped.startswith("-"):
|
|
154
|
+
break
|
|
155
|
+
if ":" not in stripped:
|
|
156
|
+
raise HarnessError(f"Expected key/value near line {lineno}: {stripped}")
|
|
157
|
+
key, raw_value = stripped.split(":", 1)
|
|
158
|
+
key = key.strip()
|
|
159
|
+
raw_value = raw_value.strip()
|
|
160
|
+
index += 1
|
|
161
|
+
|
|
162
|
+
if raw_value:
|
|
163
|
+
result[key] = parse_scalar(raw_value)
|
|
164
|
+
elif index < len(lines) and lines[index][0] > current_indent:
|
|
165
|
+
value, index = _parse_block(lines, index, lines[index][0])
|
|
166
|
+
result[key] = value
|
|
167
|
+
else:
|
|
168
|
+
result[key] = {}
|
|
169
|
+
return result, index
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _parse_list(lines: list[tuple[int, str, int]], index: int, indent: int) -> tuple[list[Any], int]:
|
|
173
|
+
result: list[Any] = []
|
|
174
|
+
while index < len(lines):
|
|
175
|
+
current_indent, stripped, lineno = lines[index]
|
|
176
|
+
if current_indent < indent:
|
|
177
|
+
break
|
|
178
|
+
if current_indent > indent:
|
|
179
|
+
raise HarnessError(f"Unexpected list indentation near line {lineno}: {stripped}")
|
|
180
|
+
if not stripped.startswith("-"):
|
|
181
|
+
break
|
|
182
|
+
|
|
183
|
+
content = stripped[1:].strip()
|
|
184
|
+
index += 1
|
|
185
|
+
|
|
186
|
+
if not content:
|
|
187
|
+
if index < len(lines) and lines[index][0] > current_indent:
|
|
188
|
+
value, index = _parse_block(lines, index, lines[index][0])
|
|
189
|
+
else:
|
|
190
|
+
value = None
|
|
191
|
+
elif _looks_like_inline_mapping(content):
|
|
192
|
+
key, raw_value = content.split(":", 1)
|
|
193
|
+
item: dict[str, Any] = {}
|
|
194
|
+
raw_value = raw_value.strip()
|
|
195
|
+
if raw_value:
|
|
196
|
+
item[key.strip()] = parse_scalar(raw_value)
|
|
197
|
+
elif index < len(lines) and lines[index][0] > current_indent:
|
|
198
|
+
nested, index = _parse_block(lines, index, lines[index][0])
|
|
199
|
+
item[key.strip()] = nested
|
|
200
|
+
else:
|
|
201
|
+
item[key.strip()] = {}
|
|
202
|
+
|
|
203
|
+
if index < len(lines) and lines[index][0] > current_indent:
|
|
204
|
+
continuation, index = _parse_block(lines, index, lines[index][0])
|
|
205
|
+
if not isinstance(continuation, dict):
|
|
206
|
+
raise HarnessError(f"Expected mapping continuation near line {lineno}: {stripped}")
|
|
207
|
+
item.update(continuation)
|
|
208
|
+
value = item
|
|
209
|
+
else:
|
|
210
|
+
value = parse_scalar(content)
|
|
211
|
+
|
|
212
|
+
result.append(value)
|
|
213
|
+
return result, index
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _looks_like_inline_mapping(content: str) -> bool:
|
|
217
|
+
if ":" not in content:
|
|
218
|
+
return False
|
|
219
|
+
if content.startswith(("'", '"')):
|
|
220
|
+
return False
|
|
221
|
+
return bool(re.match(r"^[A-Za-z0-9_.-]+\s*:", content))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def parse_scalar(raw: str) -> Any:
|
|
225
|
+
raw = raw.strip()
|
|
226
|
+
if raw in {"[]", "[ ]"}:
|
|
227
|
+
return []
|
|
228
|
+
if raw in {"{}", "{ }"}:
|
|
229
|
+
return {}
|
|
230
|
+
if raw.lower() in {"null", "none", "~"}:
|
|
231
|
+
return None
|
|
232
|
+
if raw.lower() == "true":
|
|
233
|
+
return True
|
|
234
|
+
if raw.lower() == "false":
|
|
235
|
+
return False
|
|
236
|
+
if raw.startswith("[") and raw.endswith("]"):
|
|
237
|
+
try:
|
|
238
|
+
return json.loads(raw)
|
|
239
|
+
except Exception:
|
|
240
|
+
inner = raw[1:-1].strip()
|
|
241
|
+
return [] if not inner else [parse_scalar(part.strip()) for part in inner.split(",")]
|
|
242
|
+
if raw.startswith(('"', "'")) and raw.endswith(('"', "'")):
|
|
243
|
+
try:
|
|
244
|
+
return json.loads(raw)
|
|
245
|
+
except Exception:
|
|
246
|
+
return raw[1:-1]
|
|
247
|
+
if re.match(r"^-?\d+$", raw):
|
|
248
|
+
return int(raw)
|
|
249
|
+
return raw
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def to_simple_yaml(data: Any, indent: int = 0) -> str:
|
|
253
|
+
pad = " " * indent
|
|
254
|
+
if isinstance(data, dict):
|
|
255
|
+
lines: list[str] = []
|
|
256
|
+
for key, value in data.items():
|
|
257
|
+
if _is_scalar(value) or value == [] or value == {}:
|
|
258
|
+
lines.append(f"{pad}{key}: {_format_scalar(value)}")
|
|
259
|
+
else:
|
|
260
|
+
lines.append(f"{pad}{key}:")
|
|
261
|
+
lines.append(to_simple_yaml(value, indent + 2))
|
|
262
|
+
return "\n".join(lines)
|
|
263
|
+
if isinstance(data, list):
|
|
264
|
+
if not data:
|
|
265
|
+
return f"{pad}[]"
|
|
266
|
+
lines = []
|
|
267
|
+
for item in data:
|
|
268
|
+
if _is_scalar(item) or item == [] or item == {}:
|
|
269
|
+
lines.append(f"{pad}- {_format_scalar(item)}")
|
|
270
|
+
elif isinstance(item, dict):
|
|
271
|
+
item_lines = list(item.items())
|
|
272
|
+
first_key, first_value = item_lines[0]
|
|
273
|
+
if _is_scalar(first_value) or first_value == [] or first_value == {}:
|
|
274
|
+
lines.append(f"{pad}- {first_key}: {_format_scalar(first_value)}")
|
|
275
|
+
else:
|
|
276
|
+
lines.append(f"{pad}- {first_key}:")
|
|
277
|
+
lines.append(to_simple_yaml(first_value, indent + 2))
|
|
278
|
+
for key, value in item_lines[1:]:
|
|
279
|
+
if _is_scalar(value) or value == [] or value == {}:
|
|
280
|
+
lines.append(f"{pad} {key}: {_format_scalar(value)}")
|
|
281
|
+
else:
|
|
282
|
+
lines.append(f"{pad} {key}:")
|
|
283
|
+
lines.append(to_simple_yaml(value, indent + 4))
|
|
284
|
+
else:
|
|
285
|
+
raise HarnessError(f"Cannot serialize YAML item of type {type(item).__name__}")
|
|
286
|
+
return "\n".join(lines)
|
|
287
|
+
return f"{pad}{_format_scalar(data)}"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _is_scalar(value: Any) -> bool:
|
|
291
|
+
return value is None or isinstance(value, (str, int, float, bool))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _format_scalar(value: Any) -> str:
|
|
295
|
+
if value == []:
|
|
296
|
+
return "[]"
|
|
297
|
+
if value == {}:
|
|
298
|
+
return "{}"
|
|
299
|
+
if value is None:
|
|
300
|
+
return "null"
|
|
301
|
+
if value is True:
|
|
302
|
+
return "true"
|
|
303
|
+
if value is False:
|
|
304
|
+
return "false"
|
|
305
|
+
if isinstance(value, (int, float)):
|
|
306
|
+
return str(value)
|
|
307
|
+
return json.dumps(str(value), ensure_ascii=False)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def markdown_deliverables(relative_dir: str) -> list[Path]:
|
|
311
|
+
directory = repo_path(relative_dir)
|
|
312
|
+
if not directory.exists():
|
|
313
|
+
raise HarnessError(f"Missing required directory: {relative_dir}")
|
|
314
|
+
return sorted(
|
|
315
|
+
path
|
|
316
|
+
for path in directory.rglob("*.md")
|
|
317
|
+
if path.name.upper() not in {"README.MD", "OVERVIEW.MD"} and not path.name.startswith(".")
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def require(condition: Any, message: str) -> None:
|
|
322
|
+
if not condition:
|
|
323
|
+
raise HarnessError(message)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def require_paths(paths: list[str]) -> None:
|
|
327
|
+
for relative in paths:
|
|
328
|
+
require(repo_path(relative).exists(), f"Missing required path: {relative}")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def combined_text(paths: list[Path]) -> str:
|
|
332
|
+
return "\n".join(path.read_text(encoding="utf-8") for path in paths)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def contains_any(text: str, terms: list[str]) -> bool:
|
|
336
|
+
lowered = text.lower()
|
|
337
|
+
return any(term.lower() in lowered for term in terms)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
EVIDENCE_PLACEHOLDER_TERMS = ["pending", "tbd", "todo", "placeholder", "待填", "待补", "待确认"]
|
|
341
|
+
APPLICATION_READINESS_TASK_TERMS = [
|
|
342
|
+
"service",
|
|
343
|
+
"agent",
|
|
344
|
+
"runtime",
|
|
345
|
+
"http",
|
|
346
|
+
"server",
|
|
347
|
+
"worker",
|
|
348
|
+
"provider",
|
|
349
|
+
"adapter",
|
|
350
|
+
"live mode",
|
|
351
|
+
"live",
|
|
352
|
+
"external integration",
|
|
353
|
+
"webhook",
|
|
354
|
+
"bot",
|
|
355
|
+
"机器人",
|
|
356
|
+
"常驻",
|
|
357
|
+
"云端",
|
|
358
|
+
"入口",
|
|
359
|
+
"出口",
|
|
360
|
+
]
|
|
361
|
+
PAGE_TASK_TERMS = ["frontend", "front-end", "browser", "page", "页面", "前端", "按钮", "表单", "跳转"]
|
|
362
|
+
CALLABLE_TASK_TERMS = [
|
|
363
|
+
"api",
|
|
364
|
+
"endpoint",
|
|
365
|
+
"cli",
|
|
366
|
+
"command",
|
|
367
|
+
"worker",
|
|
368
|
+
"route",
|
|
369
|
+
"server action",
|
|
370
|
+
"adapter",
|
|
371
|
+
"provider",
|
|
372
|
+
"rpa",
|
|
373
|
+
"bot",
|
|
374
|
+
"机器人",
|
|
375
|
+
"队列",
|
|
376
|
+
]
|
|
377
|
+
SELF_TEST_CONTRACT_STATUSES = {"required", "not_applicable"}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def as_string_list(value: Any) -> list[str]:
|
|
381
|
+
if isinstance(value, list):
|
|
382
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
383
|
+
if isinstance(value, str) and value.strip():
|
|
384
|
+
return [value.strip()]
|
|
385
|
+
return []
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def is_placeholder_evidence(value: str) -> bool:
|
|
389
|
+
normalized = value.strip().lower()
|
|
390
|
+
if not normalized or normalized in {"-", "n/a", "na", "none", "null", "不适用", "无"}:
|
|
391
|
+
return True
|
|
392
|
+
return any(term == normalized or term in normalized for term in EVIDENCE_PLACEHOLDER_TERMS)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def task_text_for_contract(task: dict[str, Any]) -> str:
|
|
396
|
+
parts = [str(task.get(key) or "") for key in ["id", "title", "summary", "phase"] if task.get(key)]
|
|
397
|
+
docs = task.get("docs")
|
|
398
|
+
if isinstance(docs, dict):
|
|
399
|
+
for value in docs.values():
|
|
400
|
+
parts.extend(as_string_list(value))
|
|
401
|
+
return "\n".join(parts)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def needs_runnable_task_contract(task: dict[str, Any]) -> bool:
|
|
405
|
+
if task.get("phase") != "SPRINTING":
|
|
406
|
+
return False
|
|
407
|
+
evidence_level = task.get("evidence_level")
|
|
408
|
+
target_runtime = task.get("target_runtime_environment")
|
|
409
|
+
if (
|
|
410
|
+
isinstance(evidence_level, dict)
|
|
411
|
+
and isinstance(target_runtime, dict)
|
|
412
|
+
and evidence_level.get("required") == "unit"
|
|
413
|
+
and target_runtime.get("kind") == "not_applicable"
|
|
414
|
+
):
|
|
415
|
+
return False
|
|
416
|
+
context = task_text_for_contract(task).lower()
|
|
417
|
+
return contains_any(context, APPLICATION_READINESS_TASK_TERMS + PAGE_TASK_TERMS + CALLABLE_TASK_TERMS)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def self_test_contract_errors_for_task(task: dict[str, Any]) -> list[str]:
|
|
421
|
+
task_id = str(task.get("id") or "Task")
|
|
422
|
+
required_for_runnable = needs_runnable_task_contract(task)
|
|
423
|
+
contract = task.get("self_test_contract")
|
|
424
|
+
errors: list[str] = []
|
|
425
|
+
if required_for_runnable and not isinstance(contract, dict):
|
|
426
|
+
return [f"{task_id} runtime/app task must define self_test_contract"]
|
|
427
|
+
if contract is None:
|
|
428
|
+
return []
|
|
429
|
+
if not isinstance(contract, dict):
|
|
430
|
+
return [f"{task_id} self_test_contract must be a mapping"]
|
|
431
|
+
|
|
432
|
+
status = str(contract.get("status") or "")
|
|
433
|
+
if status not in SELF_TEST_CONTRACT_STATUSES:
|
|
434
|
+
errors.append(f"{task_id} self_test_contract.status must be required or not_applicable")
|
|
435
|
+
if required_for_runnable and status != "required":
|
|
436
|
+
errors.append(f"{task_id} runnable boundary task self_test_contract.status must be required")
|
|
437
|
+
|
|
438
|
+
if status == "not_applicable":
|
|
439
|
+
reason = str(contract.get("not_applicable_reason") or "").strip()
|
|
440
|
+
if len(reason) < 24 or is_placeholder_evidence(reason):
|
|
441
|
+
errors.append(f"{task_id} self_test_contract.not_applicable_reason must explain why self-test is not applicable")
|
|
442
|
+
return errors
|
|
443
|
+
if status != "required":
|
|
444
|
+
return errors
|
|
445
|
+
|
|
446
|
+
for field in ["source", "runnable_entry", "observable_exit", "module_key_test_path"]:
|
|
447
|
+
value = str(contract.get(field) or "").strip()
|
|
448
|
+
if not value or is_placeholder_evidence(value):
|
|
449
|
+
errors.append(f"{task_id} self_test_contract.{field} must be concrete")
|
|
450
|
+
if not as_string_list(contract.get("capability_refs")):
|
|
451
|
+
errors.append(f"{task_id} self_test_contract.capability_refs must be a non-empty list")
|
|
452
|
+
|
|
453
|
+
required_gates = as_string_list(contract.get("required_gates"))
|
|
454
|
+
if not required_gates:
|
|
455
|
+
errors.append(f"{task_id} self_test_contract.required_gates must be a non-empty list")
|
|
456
|
+
task_gates = set(as_string_list(task.get("required_gates")))
|
|
457
|
+
for gate in required_gates:
|
|
458
|
+
if gate not in task_gates:
|
|
459
|
+
errors.append(f"{task_id} self_test_contract.required_gates must also appear in task required_gates: {gate}")
|
|
460
|
+
|
|
461
|
+
scenarios = contract.get("scenarios")
|
|
462
|
+
if not isinstance(scenarios, list) or not scenarios:
|
|
463
|
+
errors.append(f"{task_id} self_test_contract.scenarios must be a non-empty list")
|
|
464
|
+
return errors
|
|
465
|
+
seen: set[str] = set()
|
|
466
|
+
for index, scenario in enumerate(scenarios):
|
|
467
|
+
if not isinstance(scenario, dict):
|
|
468
|
+
errors.append(f"{task_id} self_test_contract.scenarios[{index}] must be a mapping")
|
|
469
|
+
continue
|
|
470
|
+
scenario_id = str(scenario.get("id") or "").strip()
|
|
471
|
+
if not scenario_id:
|
|
472
|
+
errors.append(f"{task_id} self_test_contract.scenarios[{index}].id must be set")
|
|
473
|
+
elif scenario_id in seen:
|
|
474
|
+
errors.append(f"{task_id} self_test_contract scenario id must be unique: {scenario_id}")
|
|
475
|
+
seen.add(scenario_id)
|
|
476
|
+
for field in ["entry", "expected_exit", "evidence"]:
|
|
477
|
+
value = str(scenario.get(field) or "").strip()
|
|
478
|
+
if not value or is_placeholder_evidence(value):
|
|
479
|
+
errors.append(f"{task_id} self_test_contract.scenarios[{scenario_id or index}].{field} must be concrete")
|
|
480
|
+
return errors
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
TESTING_DISALLOWED_ALLOWED_PATHS = [
|
|
484
|
+
"package.json",
|
|
485
|
+
"**/package.json",
|
|
486
|
+
"package-lock.json",
|
|
487
|
+
"**/package-lock.json",
|
|
488
|
+
"npm-shrinkwrap.json",
|
|
489
|
+
"**/npm-shrinkwrap.json",
|
|
490
|
+
"pnpm-lock.yaml",
|
|
491
|
+
"**/pnpm-lock.yaml",
|
|
492
|
+
"yarn.lock",
|
|
493
|
+
"**/yarn.lock",
|
|
494
|
+
"bun.lock",
|
|
495
|
+
"**/bun.lock",
|
|
496
|
+
"bun.lockb",
|
|
497
|
+
"**/bun.lockb",
|
|
498
|
+
"src/**",
|
|
499
|
+
"app/**",
|
|
500
|
+
"lib/**",
|
|
501
|
+
"server/**",
|
|
502
|
+
"bin/**",
|
|
503
|
+
"cli/**",
|
|
504
|
+
"runtime/**",
|
|
505
|
+
"scripts/**",
|
|
506
|
+
"tools/**",
|
|
507
|
+
"deploy/**",
|
|
508
|
+
"deployment/**",
|
|
509
|
+
"infra/**",
|
|
510
|
+
"ops/**",
|
|
511
|
+
"systemd/**",
|
|
512
|
+
".github/workflows/**",
|
|
513
|
+
"dockerfile",
|
|
514
|
+
"dockerfile.*",
|
|
515
|
+
"docker-compose*.yml",
|
|
516
|
+
"docker-compose*.yaml",
|
|
517
|
+
"*.service",
|
|
518
|
+
"tests/runtime/**",
|
|
519
|
+
"tests/**/runtime/**",
|
|
520
|
+
]
|
|
521
|
+
|
|
522
|
+
TESTING_DISALLOWED_CHANGED_PATHS = TESTING_DISALLOWED_ALLOWED_PATHS + [
|
|
523
|
+
"scripts/**",
|
|
524
|
+
"tools/**",
|
|
525
|
+
]
|
|
526
|
+
|
|
527
|
+
TESTING_RUNTIME_FILE_TERMS = [
|
|
528
|
+
"bootstrap",
|
|
529
|
+
"cloud",
|
|
530
|
+
"daemon",
|
|
531
|
+
"poller",
|
|
532
|
+
"provider",
|
|
533
|
+
"runtime",
|
|
534
|
+
"service",
|
|
535
|
+
"systemd",
|
|
536
|
+
]
|
|
537
|
+
|
|
538
|
+
TESTING_ALLOWED_TEST_FILE_TERMS = [
|
|
539
|
+
"assertion",
|
|
540
|
+
"fixture",
|
|
541
|
+
"mock",
|
|
542
|
+
"smoke",
|
|
543
|
+
]
|
|
544
|
+
|
|
545
|
+
TEST_FACT_SOURCE_PHASES = {"TESTING", "RFC_RECALIBRATION"}
|
|
546
|
+
TEST_FACT_SOURCE_PATTERNS = [".docs/07_test/**", ".docs/07_test/"]
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def test_fact_source_errors_for_task(task: dict[str, Any]) -> list[str]:
|
|
550
|
+
phase = str(task.get("phase") or "")
|
|
551
|
+
if phase in TEST_FACT_SOURCE_PHASES:
|
|
552
|
+
return []
|
|
553
|
+
candidates = [str(path) for path in task.get("allowed_paths") or []]
|
|
554
|
+
candidates.extend(str(path) for path in task.get("result_docs") or [])
|
|
555
|
+
blocked = [
|
|
556
|
+
path
|
|
557
|
+
for path in candidates
|
|
558
|
+
if matches_any(path.replace("\\", "/"), TEST_FACT_SOURCE_PATTERNS)
|
|
559
|
+
or path.replace("\\", "/").startswith(".docs/07_test/")
|
|
560
|
+
]
|
|
561
|
+
if not blocked:
|
|
562
|
+
return []
|
|
563
|
+
return [
|
|
564
|
+
"Only TESTING or RFC_RECALIBRATION tasks may target current test fact sources under .docs/07_test/**: "
|
|
565
|
+
+ ", ".join(blocked)
|
|
566
|
+
]
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def testing_boundary_errors_for_allowed_paths(task: dict[str, Any]) -> list[str]:
|
|
570
|
+
if task.get("phase") != "TESTING":
|
|
571
|
+
return []
|
|
572
|
+
blocked = []
|
|
573
|
+
for raw_path in task.get("allowed_paths") or []:
|
|
574
|
+
path = str(raw_path)
|
|
575
|
+
lowered = path.lower()
|
|
576
|
+
if matches_any(lowered, TESTING_DISALLOWED_ALLOWED_PATHS) or lowered in {
|
|
577
|
+
"package.json",
|
|
578
|
+
"package-lock.json",
|
|
579
|
+
"pnpm-lock.yaml",
|
|
580
|
+
"yarn.lock",
|
|
581
|
+
"bun.lock",
|
|
582
|
+
"bun.lockb",
|
|
583
|
+
}:
|
|
584
|
+
blocked.append(path)
|
|
585
|
+
if not blocked:
|
|
586
|
+
return []
|
|
587
|
+
return [
|
|
588
|
+
"TESTING task allowed_paths must not include product runtime, package/deploy config, or long-running runtime paths: "
|
|
589
|
+
+ ", ".join(blocked)
|
|
590
|
+
]
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def testing_boundary_errors_for_changed_files(files: list[str]) -> list[str]:
|
|
594
|
+
blocked = [path for path in files if is_testing_runtime_boundary_change(path)]
|
|
595
|
+
if not blocked:
|
|
596
|
+
return []
|
|
597
|
+
return [
|
|
598
|
+
"TESTING changes must use existing product entrypoints only; move runtime, bootstrap, provider, deploy, or package script changes to SPRINTING/RFC: "
|
|
599
|
+
+ ", ".join(blocked)
|
|
600
|
+
]
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def is_testing_runtime_boundary_change(path: str) -> bool:
|
|
604
|
+
normalized = path.replace("\\", "/")
|
|
605
|
+
lowered = normalized.lower()
|
|
606
|
+
if matches_any(lowered, TESTING_DISALLOWED_CHANGED_PATHS):
|
|
607
|
+
return True
|
|
608
|
+
if lowered.startswith("tests/"):
|
|
609
|
+
name = Path(lowered).name
|
|
610
|
+
if any(term in name for term in TESTING_ALLOWED_TEST_FILE_TERMS):
|
|
611
|
+
return False
|
|
612
|
+
return any(term in name for term in TESTING_RUNTIME_FILE_TERMS)
|
|
613
|
+
return False
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def load_lifecycle() -> dict[str, Any]:
|
|
617
|
+
data = load_yaml(".codex/state/lifecycle.yaml")
|
|
618
|
+
require(isinstance(data, dict), "lifecycle.yaml must be a mapping")
|
|
619
|
+
return data
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def load_phase_contracts() -> dict[str, Any]:
|
|
623
|
+
data = load_yaml(".codex/pjsdlc_managed/policies/phase_contracts.yaml")
|
|
624
|
+
require(isinstance(data, dict) and isinstance(data.get("phases"), dict), "phase_contracts.yaml must contain phases")
|
|
625
|
+
return data["phases"]
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def load_plan(path: str = ".codex/state/plan.yaml") -> dict[str, Any]:
|
|
629
|
+
data = load_yaml(path)
|
|
630
|
+
require(isinstance(data, dict), f"{path} must be a mapping")
|
|
631
|
+
tasks = data.get("tasks", [])
|
|
632
|
+
require(isinstance(tasks, list), f"{path} must contain a tasks list")
|
|
633
|
+
return data
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def validate_task_shape(task: dict[str, Any], index: int) -> None:
|
|
637
|
+
prefix = f"Task #{index + 1}"
|
|
638
|
+
for field in ["id", "title", "status", "summary"]:
|
|
639
|
+
require(field in task, f"{prefix} missing field: {field}")
|
|
640
|
+
task_id = str(task.get("id") or "")
|
|
641
|
+
require(TASK_ID_PATTERN.match(task_id), f"{task_id or prefix} id must match PREFIX-###")
|
|
642
|
+
if task_id.startswith("TASK-"):
|
|
643
|
+
require(task.get("phase") in TASK_PHASES, f"{task_id} must define valid phase")
|
|
644
|
+
elif task.get("phase") is not None:
|
|
645
|
+
require(task.get("phase") in TASK_PHASES, f"{task_id} has invalid phase: {task.get('phase')}")
|
|
646
|
+
require(task["status"] in TASK_STATUSES, f"{task.get('id', prefix)} has invalid status: {task.get('status')}")
|
|
647
|
+
require(isinstance(task["summary"], str) and task["summary"].strip(), f"{task['id']} must define summary")
|
|
648
|
+
has_implementation_doc = isinstance(task.get("implementation_doc"), str) and task["implementation_doc"].strip()
|
|
649
|
+
has_result_docs = isinstance(task.get("result_docs"), list) and bool(task["result_docs"])
|
|
650
|
+
require(has_implementation_doc or has_result_docs, f"{task['id']} must define implementation_doc or result_docs")
|
|
651
|
+
for error in test_fact_source_errors_for_task(task):
|
|
652
|
+
require(False, f"{task['id']} {error}")
|
|
653
|
+
if task["status"] in OPEN_TASK_STATUSES:
|
|
654
|
+
require("gate_result" not in task, f"{task['id']} open task must not define gate_result")
|
|
655
|
+
for field in ["docs", "allowed_paths", "required_gates", "acceptance_criteria"]:
|
|
656
|
+
require(field in task, f"{task['id']} open task missing field: {field}")
|
|
657
|
+
require(isinstance(task["docs"], dict), f"{task['id']} docs must be a mapping")
|
|
658
|
+
require(isinstance(task["allowed_paths"], list) and task["allowed_paths"], f"{task['id']} must define allowed_paths")
|
|
659
|
+
require(isinstance(task["required_gates"], list) and task["required_gates"], f"{task['id']} must define required_gates")
|
|
660
|
+
require(isinstance(task["acceptance_criteria"], list) and task["acceptance_criteria"], f"{task['id']} must define acceptance_criteria")
|
|
661
|
+
for error in self_test_contract_errors_for_task(task):
|
|
662
|
+
require(False, error)
|
|
663
|
+
for error in testing_boundary_errors_for_allowed_paths(task):
|
|
664
|
+
require(False, f"{task['id']} {error}")
|
|
665
|
+
else:
|
|
666
|
+
for field in ["docs", "allowed_paths", "required_gates", "acceptance_criteria", "working_notes", "gate_result", "result_docs"]:
|
|
667
|
+
require(field not in task, f"{task['id']} closed task must not retain {field}")
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def task_sequence_number(task_id: str) -> int:
|
|
671
|
+
match = TASK_ID_PATTERN.match(task_id)
|
|
672
|
+
return int(match.group(1)) if match else 0
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def validate_plan_contract(data: dict[str, Any], allow_open: bool) -> None:
|
|
676
|
+
lifecycle = load_lifecycle()
|
|
677
|
+
current_phase = str(lifecycle.get("current_phase") or "")
|
|
678
|
+
require("current_phase" not in data, "plan.yaml must not define current_phase; lifecycle.yaml is the single source for current_phase")
|
|
679
|
+
validate_parallel_execution_contract(data, current_phase)
|
|
680
|
+
tasks = data.get("tasks", [])
|
|
681
|
+
next_task_sequence = data.get("next_task_sequence")
|
|
682
|
+
require(isinstance(next_task_sequence, int) and next_task_sequence > 0, "plan.yaml must define positive integer next_task_sequence")
|
|
683
|
+
|
|
684
|
+
for index, task in enumerate(tasks):
|
|
685
|
+
require(isinstance(task, dict), f"Task #{index + 1} must be a mapping")
|
|
686
|
+
validate_task_shape(task, index)
|
|
687
|
+
require(task.get("status") in OPEN_TASK_STATUSES, f"Completed task {task.get('id')} must not remain in plan.yaml")
|
|
688
|
+
|
|
689
|
+
max_sequence = 0
|
|
690
|
+
for task in tasks:
|
|
691
|
+
task_id = str(task.get("id") or "")
|
|
692
|
+
max_sequence = max(max_sequence, task_sequence_number(task_id))
|
|
693
|
+
require(next_task_sequence > max_sequence, "next_task_sequence must be greater than task ids currently in plan.yaml")
|
|
694
|
+
|
|
695
|
+
current_task_id = data.get("current_task_id") or ""
|
|
696
|
+
if current_task_id:
|
|
697
|
+
require(task_by_id(data, current_task_id), f"current_task_id does not match a task: {current_task_id}")
|
|
698
|
+
|
|
699
|
+
open_tasks = [task.get("id") for task in tasks if task.get("status") in OPEN_TASK_STATUSES]
|
|
700
|
+
if not allow_open:
|
|
701
|
+
require(not open_tasks, f"Open tasks remain: {', '.join(open_tasks)}")
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def validate_parallel_execution_contract(data: dict[str, Any], current_phase: str) -> None:
|
|
705
|
+
contract = data.get("parallel_execution")
|
|
706
|
+
if contract is None:
|
|
707
|
+
return
|
|
708
|
+
|
|
709
|
+
require(isinstance(contract, dict), "parallel_execution must be a mapping")
|
|
710
|
+
require(contract.get("enabled") is True, "parallel_execution.enabled must be true when present")
|
|
711
|
+
require(contract.get("trigger") in PARALLEL_TRIGGERS, "parallel_execution.trigger must be user_requested or workflow_default")
|
|
712
|
+
require(contract.get("mode") in PARALLEL_MODES, "parallel_execution.mode must be runtime_managed or user_orchestrated")
|
|
713
|
+
provider = parallel_runtime_provider(contract)
|
|
714
|
+
if provider:
|
|
715
|
+
require(provider in PARALLEL_RUNTIME_PROVIDERS, "parallel_execution.runtime.provider must be codex_native_subagents, user_orchestrated, or codex_exec_worktree")
|
|
716
|
+
if contract.get("trigger") == "workflow_default":
|
|
717
|
+
require(provider == "codex_native_subagents", 'parallel_execution.runtime.provider must be "codex_native_subagents" when trigger is workflow_default')
|
|
718
|
+
require("phase" not in contract, "parallel_execution must not define phase; lifecycle.yaml is the single source for current_phase")
|
|
719
|
+
require("linked_task_id" not in contract, "parallel_execution must not define linked_task_id; use plan.yaml current_task_id")
|
|
720
|
+
require(
|
|
721
|
+
current_phase in PARALLEL_ALLOWED_PHASES,
|
|
722
|
+
"parallel_execution is only supported during REQUIREMENT_GATHERING, ARCHITECTING, SPRINTING, REVIEWING, TESTING, RELEASING, or RFC_RECALIBRATION",
|
|
723
|
+
)
|
|
724
|
+
require(contract.get("coordinator") == "main_agent", 'parallel_execution.coordinator must be "main_agent"')
|
|
725
|
+
|
|
726
|
+
if current_phase == "SPRINTING":
|
|
727
|
+
require(data.get("current_task_id"), "SPRINTING parallel_execution requires plan.yaml current_task_id")
|
|
728
|
+
|
|
729
|
+
workers = contract.get("workers")
|
|
730
|
+
require(isinstance(workers, list) and workers, "parallel_execution.workers must be a non-empty list")
|
|
731
|
+
seen_ids: set[str] = set()
|
|
732
|
+
write_owned_paths: list[tuple[int, str]] = []
|
|
733
|
+
for index, worker in enumerate(workers):
|
|
734
|
+
prefix = f"parallel_execution.workers[{index}]"
|
|
735
|
+
require(isinstance(worker, dict), f"{prefix} must be a mapping")
|
|
736
|
+
worker_id = worker.get("id")
|
|
737
|
+
require(isinstance(worker_id, str) and worker_id.strip(), f"{prefix}.id must be a non-empty string")
|
|
738
|
+
require(worker_id not in seen_ids, f"parallel_execution worker id must be unique: {worker_id}")
|
|
739
|
+
seen_ids.add(worker_id)
|
|
740
|
+
require(isinstance(worker.get("writes_repo"), bool), f"{prefix}.writes_repo must be a boolean")
|
|
741
|
+
for field in ["owned_paths", "forbidden_paths", "expected_output", "required_gates"]:
|
|
742
|
+
require(isinstance(worker.get(field), list), f"{prefix}.{field} must be a list")
|
|
743
|
+
require(worker.get("expected_output"), f"{prefix}.expected_output must not be empty")
|
|
744
|
+
require(worker.get("required_gates"), f"{prefix}.required_gates must not be empty")
|
|
745
|
+
if current_phase in PARALLEL_READ_ONLY_PHASES:
|
|
746
|
+
require(worker.get("writes_repo") is False, f"{prefix}.writes_repo must be false during {current_phase}")
|
|
747
|
+
if worker.get("writes_repo") is True:
|
|
748
|
+
if provider != "codex_native_subagents":
|
|
749
|
+
require(isinstance(worker.get("branch"), str) and worker["branch"].strip(), f"{prefix}.branch is required when writes_repo is true outside codex_native_subagents runtime")
|
|
750
|
+
require(isinstance(worker.get("worktree"), str) and worker["worktree"].strip(), f"{prefix}.worktree is required when writes_repo is true outside codex_native_subagents runtime")
|
|
751
|
+
require(worker.get("owned_paths"), f"{prefix}.owned_paths must not be empty when writes_repo is true")
|
|
752
|
+
validate_parallel_worker_path_lock(data, worker, index)
|
|
753
|
+
for owned in expand_harness_root(list(worker.get("owned_paths") or [])):
|
|
754
|
+
write_owned_paths.append((index, owned))
|
|
755
|
+
|
|
756
|
+
for left_pos, (left_index, left_path) in enumerate(write_owned_paths):
|
|
757
|
+
for right_index, right_path in write_owned_paths[left_pos + 1 :]:
|
|
758
|
+
require(
|
|
759
|
+
not glob_patterns_overlap(left_path, right_path),
|
|
760
|
+
f"parallel_execution write worker owned_paths must not overlap: workers[{left_index}] {left_path} vs workers[{right_index}] {right_path}",
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
integration = contract.get("integration")
|
|
764
|
+
require(isinstance(integration, dict), "parallel_execution.integration must be a mapping")
|
|
765
|
+
require(integration.get("owner") == "main_agent", 'parallel_execution.integration.owner must be "main_agent"')
|
|
766
|
+
require(isinstance(integration.get("merge_strategy"), str) and integration["merge_strategy"].strip(), "parallel_execution.integration.merge_strategy must be a non-empty string")
|
|
767
|
+
require(isinstance(integration.get("required_gates"), list) and integration["required_gates"], "parallel_execution.integration.required_gates must be a non-empty list")
|
|
768
|
+
require(isinstance(integration.get("fact_source_updates"), list) and integration["fact_source_updates"], "parallel_execution.integration.fact_source_updates must be a non-empty list")
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def parallel_runtime_provider(contract: dict[str, Any]) -> str:
|
|
772
|
+
runtime = contract.get("runtime")
|
|
773
|
+
if runtime is None:
|
|
774
|
+
return ""
|
|
775
|
+
require(isinstance(runtime, dict), "parallel_execution.runtime must be a mapping when present")
|
|
776
|
+
provider = runtime.get("provider")
|
|
777
|
+
return str(provider or "")
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def validate_parallel_worker_path_lock(data: dict[str, Any], worker: dict[str, Any], index: int) -> None:
|
|
781
|
+
current_task = task_by_id(data, str(data.get("current_task_id") or ""))
|
|
782
|
+
if current_task is None:
|
|
783
|
+
return
|
|
784
|
+
task_allowed = expand_harness_root(list(current_task.get("allowed_paths") or []))
|
|
785
|
+
worker_owned = expand_harness_root(list(worker.get("owned_paths") or []))
|
|
786
|
+
worker_forbidden = expand_harness_root(list(worker.get("forbidden_paths") or []))
|
|
787
|
+
protected = expand_harness_root(list(PARALLEL_PROTECTED_WRITE_PATTERNS))
|
|
788
|
+
prefix = f"parallel_execution.workers[{index}]"
|
|
789
|
+
for owned in worker_owned:
|
|
790
|
+
require(matches_any(owned, task_allowed), f"{prefix}.owned_paths must be within current task allowed_paths: {owned}")
|
|
791
|
+
for forbidden in [*worker_forbidden, *protected]:
|
|
792
|
+
require(not glob_patterns_overlap(owned, forbidden), f"{prefix}.owned_paths must not overlap forbidden paths: {owned} vs {forbidden}")
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def glob_prefix(pattern: str) -> str:
|
|
796
|
+
normalized = pattern.replace("\\", "/").replace("<harnessRoot>", ".codex")
|
|
797
|
+
wildcard_positions = [pos for pos in (normalized.find("*"), normalized.find("["), normalized.find("?")) if pos >= 0]
|
|
798
|
+
if wildcard_positions:
|
|
799
|
+
normalized = normalized[: min(wildcard_positions)]
|
|
800
|
+
return normalized.rstrip("/")
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def glob_patterns_overlap(left: str, right: str) -> bool:
|
|
804
|
+
left_clean = left.replace("\\", "/").replace("<harnessRoot>", ".codex")
|
|
805
|
+
right_clean = right.replace("\\", "/").replace("<harnessRoot>", ".codex")
|
|
806
|
+
if fnmatch.fnmatch(left_clean, right_clean) or fnmatch.fnmatch(right_clean, left_clean):
|
|
807
|
+
return True
|
|
808
|
+
left_prefix = glob_prefix(left_clean)
|
|
809
|
+
right_prefix = glob_prefix(right_clean)
|
|
810
|
+
if not left_prefix or not right_prefix:
|
|
811
|
+
return left_prefix == right_prefix
|
|
812
|
+
return left_prefix.startswith(right_prefix + "/") or right_prefix.startswith(left_prefix + "/") or left_prefix == right_prefix
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def expand_harness_root(patterns: list[str], root: str = ".codex") -> list[str]:
|
|
816
|
+
return [str(pattern).replace("<harnessRoot>", root) for pattern in patterns]
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def task_by_id(plan_data: dict[str, Any], task_id: str) -> dict[str, Any] | None:
|
|
820
|
+
for task in plan_data.get("tasks", []):
|
|
821
|
+
if isinstance(task, dict) and task.get("id") == task_id:
|
|
822
|
+
return task
|
|
823
|
+
return None
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def changed_files() -> list[str]:
|
|
827
|
+
files: set[str] = set()
|
|
828
|
+
commands = [
|
|
829
|
+
["git", "diff", "--name-only"],
|
|
830
|
+
["git", "diff", "--cached", "--name-only"],
|
|
831
|
+
["git", "ls-files", "--others", "--exclude-standard"],
|
|
832
|
+
]
|
|
833
|
+
for command in commands:
|
|
834
|
+
proc = subprocess.run(command, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
835
|
+
if proc.returncode == 0:
|
|
836
|
+
files.update(line.strip() for line in proc.stdout.splitlines() if line.strip())
|
|
837
|
+
return sorted(files)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def matches_any(path: str, patterns: list[str]) -> bool:
|
|
841
|
+
normalized = path.replace("\\", "/")
|
|
842
|
+
for pattern in patterns:
|
|
843
|
+
clean = str(pattern).replace("\\", "/")
|
|
844
|
+
if fnmatch.fnmatch(normalized, clean) or fnmatch.fnmatch(normalized, clean.rstrip("/") + "/**"):
|
|
845
|
+
return True
|
|
846
|
+
return False
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def run_main(main) -> None:
|
|
850
|
+
try:
|
|
851
|
+
main()
|
|
852
|
+
except HarnessError as exc:
|
|
853
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
854
|
+
sys.exit(1)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def make_arg_parser(description: str) -> argparse.ArgumentParser:
|
|
858
|
+
return argparse.ArgumentParser(description=description)
|