@blxzer/cursor-trellis 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +37 -12
  3. package/README.zh-CN.md +41 -16
  4. package/bin/{trellis.js → cstl.js} +1 -1
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +30 -4
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/commands/capability-smoke.d.ts +24 -0
  9. package/dist/commands/capability-smoke.d.ts.map +1 -0
  10. package/dist/commands/capability-smoke.js +83 -0
  11. package/dist/commands/capability-smoke.js.map +1 -0
  12. package/dist/commands/init.d.ts.map +1 -1
  13. package/dist/commands/init.js +94 -34
  14. package/dist/commands/init.js.map +1 -1
  15. package/dist/commands/uninstall.d.ts +1 -1
  16. package/dist/commands/uninstall.js +2 -2
  17. package/dist/commands/uninstall.js.map +1 -1
  18. package/dist/commands/update.d.ts +1 -1
  19. package/dist/commands/update.d.ts.map +1 -1
  20. package/dist/commands/update.js +79 -34
  21. package/dist/commands/update.js.map +1 -1
  22. package/dist/commands/upgrade.js +2 -2
  23. package/dist/commands/upgrade.js.map +1 -1
  24. package/dist/configurators/cursor.d.ts +2 -2
  25. package/dist/configurators/cursor.js +4 -4
  26. package/dist/configurators/cursor.js.map +1 -1
  27. package/dist/configurators/index.js +3 -3
  28. package/dist/configurators/index.js.map +1 -1
  29. package/dist/configurators/shared.d.ts +10 -10
  30. package/dist/configurators/shared.d.ts.map +1 -1
  31. package/dist/configurators/shared.js +26 -26
  32. package/dist/configurators/shared.js.map +1 -1
  33. package/dist/configurators/workflow.d.ts +2 -2
  34. package/dist/migrations/manifests/0.2.10.json +374 -0
  35. package/dist/migrations/manifests/0.2.9.json +9 -0
  36. package/dist/migrations/manifests/0.3.0.json +89 -0
  37. package/dist/templates/common/bundled-skills/{trellis-cursor2plus-setup → cstl-cursor2plus-setup}/SKILL.md +5 -4
  38. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/SKILL.md +75 -75
  39. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/add-project-local-conventions.md +8 -8
  40. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-agents.md +6 -6
  41. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-context-loading.md +7 -7
  42. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-skills-or-commands.md +1 -1
  43. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-spec-structure.md +2 -2
  44. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-task-lifecycle.md +31 -31
  45. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-workflow.md +12 -12
  46. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/overview.md +55 -55
  47. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/context-injection.md +13 -13
  48. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/generated-files.md +83 -83
  49. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/overview.md +2 -2
  50. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/spec-system.md +1 -1
  51. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/task-system.md +22 -22
  52. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/workflow.md +5 -5
  53. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/workspace-memory.md +3 -3
  54. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/agents.md +11 -11
  55. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/hooks-and-settings.md +1 -1
  56. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/overview.md +4 -4
  57. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/platform-map.md +5 -5
  58. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/skills-and-commands.md +15 -15
  59. package/dist/templates/common/bundled-skills/{trellis-micro-grill → cstl-micro-grill}/SKILL.md +62 -47
  60. package/dist/templates/common/bundled-skills/{trellis-skill-creator → cstl-skill-creator}/SKILL.md +44 -44
  61. package/dist/templates/common/bundled-skills/{trellis-skill-creator → cstl-skill-creator}/references/authoring-rules.md +97 -97
  62. package/dist/templates/common/bundled-skills/{trellis-skill-creator/references/trellis-skill-locations.md → cstl-skill-creator/references/cstl-skill-locations.md} +52 -52
  63. package/dist/templates/common/bundled-skills/{trellis-skill-creator → cstl-skill-creator}/references/review-checklist.md +47 -47
  64. package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/SKILL.md +1 -1
  65. package/dist/templates/common/bundled-skills/smart-search-cli/SKILL.md +7 -8
  66. package/dist/templates/common/bundled-skills/smart-search-cli/references/cli-contract.md +46 -46
  67. package/dist/templates/common/commands/continue.md +20 -20
  68. package/dist/templates/common/commands/start.md +11 -11
  69. package/dist/templates/common/index.d.ts +1 -1
  70. package/dist/templates/common/index.d.ts.map +1 -1
  71. package/dist/templates/common/skills/brainstorm.md +188 -164
  72. package/dist/templates/common/skills/check.md +1 -1
  73. package/dist/templates/cursor/agents/{trellis-check.md → cstl-check.md} +8 -8
  74. package/dist/templates/cursor/agents/{trellis-implement.md → cstl-implement.md} +8 -8
  75. package/dist/templates/cursor/agents/{trellis-research.md → cstl-research.md} +2 -2
  76. package/dist/templates/cursor/commands/cursor2plus-setup.md +5 -5
  77. package/dist/templates/cursor/fixtures/expected-rules.d.ts +1 -1
  78. package/dist/templates/cursor/fixtures/expected-rules.d.ts.map +1 -1
  79. package/dist/templates/cursor/fixtures/expected-rules.js +2 -2
  80. package/dist/templates/cursor/fixtures/expected-rules.js.map +1 -1
  81. package/dist/templates/cursor/rules/{trellis-subagent-dispatch.mdc → cstl-subagent-dispatch.mdc} +2 -2
  82. package/dist/templates/cursor/rules/{trellis-triage.mdc → cstl-triage.mdc} +1 -1
  83. package/dist/templates/markdown/agents.md +4 -4
  84. package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +634 -634
  85. package/dist/templates/markdown/spec/guides/cursor-context-injection-guide.md.txt +4 -4
  86. package/dist/templates/markdown/spec/guides/cursor-semantic-compliance.md.txt +6 -0
  87. package/dist/templates/markdown/spec/guides/cursor-subagent-policy.md.txt +393 -391
  88. package/dist/templates/markdown/spec/guides/durable-learning-decision-guide.md.txt +6 -6
  89. package/dist/templates/markdown/spec/guides/execution-strategy.md.txt +2 -2
  90. package/dist/templates/markdown/spec/guides/index.md.txt +3 -2
  91. package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +12 -0
  92. package/dist/templates/shared-hooks/index.d.ts +1 -1
  93. package/dist/templates/shared-hooks/index.js +1 -1
  94. package/dist/templates/shared-hooks/inject-subagent-context.py +3 -3
  95. package/dist/templates/shared-hooks/inject-workflow-state.py +27 -27
  96. package/dist/templates/shared-hooks/research-end-retrieval-pack.py +1 -1
  97. package/dist/templates/shared-hooks/session-start.py +3 -3
  98. package/dist/templates/trellis/config/execution-strategy-rules.json +1 -1
  99. package/dist/templates/trellis/config.yaml +4 -4
  100. package/dist/templates/trellis/local/README.md +12 -10
  101. package/dist/templates/trellis/local/patch_wpelc8.py +3 -3
  102. package/dist/templates/trellis/local/subagent-models.json.example +1 -1
  103. package/dist/templates/trellis/local/trellis-task-models.json.example +3 -3
  104. package/dist/templates/trellis/local/trellis-task-models.json5.example +5 -5
  105. package/dist/templates/trellis/local/trellis_task_models_config.py +6 -6
  106. package/dist/templates/trellis/scripts/common/cli_adapter.py +3 -3
  107. package/dist/templates/trellis/scripts/common/execution_strategy.py +1 -1
  108. package/dist/templates/trellis/scripts/common/parent_orchestration.py +702 -702
  109. package/dist/templates/trellis/scripts/common/session_context.py +1159 -1159
  110. package/dist/templates/trellis/scripts/common/smart_search_evidence.py +33 -1
  111. package/dist/templates/trellis/scripts/common/subagent_dispatch.py +5 -5
  112. package/dist/templates/trellis/scripts/common/task_gates.py +2056 -2034
  113. package/dist/templates/trellis/scripts/common/task_store.py +1594 -1594
  114. package/dist/templates/trellis/workflow.md +805 -805
  115. package/dist/types/ai-tools.d.ts +1 -1
  116. package/dist/types/ai-tools.d.ts.map +1 -1
  117. package/dist/types/ai-tools.js +1 -1
  118. package/dist/types/ai-tools.js.map +1 -1
  119. package/dist/utils/project-capabilities.d.ts +14 -3
  120. package/dist/utils/project-capabilities.d.ts.map +1 -1
  121. package/dist/utils/project-capabilities.js +122 -12
  122. package/dist/utils/project-capabilities.js.map +1 -1
  123. package/dist/utils/readiness.d.ts +10 -0
  124. package/dist/utils/readiness.d.ts.map +1 -1
  125. package/dist/utils/readiness.js +6 -3
  126. package/dist/utils/readiness.js.map +1 -1
  127. package/package.json +4 -5
  128. /package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-hooks.md +0 -0
  129. /package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/references/mcp-setup.md +0 -0
  130. /package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/references/repository-analysis.md +0 -0
  131. /package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/references/spec-task-planning.md +0 -0
  132. /package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/references/spec-writing.md +0 -0
@@ -1,2034 +1,2056 @@
1
- #!/usr/bin/env python3
2
- """
3
- Task quality gate helpers.
4
-
5
- Gate records are stored in task.json under:
6
- quality_gate_results.transitions[transition][gate]
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import hashlib
12
- import json
13
- import re
14
- from dataclasses import dataclass, field
15
- from datetime import datetime, timezone
16
- from pathlib import Path
17
-
18
- from .task_map import (
19
- get_child_state,
20
- load_task_map,
21
- validate_child_archive_state,
22
- validate_parent_children_complete,
23
- )
24
-
25
- SCHEMA_VERSION = 1
26
- BASELINE_GATE = "baseline-check"
27
-
28
- KNOWN_TRANSITIONS = {
29
- "start-execution",
30
- "full-task-complete",
31
- "child-review",
32
- "parent-changes",
33
- "parent-accepted",
34
- "parent-integrating",
35
- "parent-integrated",
36
- "parent-cancelled",
37
- }
38
-
39
- REVIEW_GATES = {
40
- "requirements-review",
41
- "code-review",
42
- "architecture-review",
43
- "architecture-deep-review",
44
- "integration-review",
45
- }
46
-
47
- KNOWN_GATES = {BASELINE_GATE, *REVIEW_GATES}
48
- RESULTS = {"PASS", "FAIL", "SKIPPED"}
49
- FAIL_ROOT_CAUSES = {
50
- "implementation-defect": "Execution",
51
- "contract-changing-defect": "Planning",
52
- "validation-environment-blocker": "Verification / Review",
53
- }
54
-
55
- PROFILE_DEFAULT_GATES = {
56
- "standard": ["requirements-review", "code-review"],
57
- "strict": ["requirements-review", "code-review"],
58
- "architecture": [
59
- "requirements-review",
60
- "architecture-review",
61
- "code-review",
62
- ],
63
- }
64
-
65
- ALLOWED_EXECUTION_MODES = {"inline", "worker", "child-task"}
66
- ALLOWED_ISOLATION = {"main-worktree", "git-worktree"}
67
- ALLOWED_VERIFICATION_PROFILES = {"standard", "strict", "architecture"}
68
- ALLOWED_RETRIEVAL_PROFILES = {
69
- "exact-only",
70
- "semantic",
71
- "structure",
72
- "architecture-memory",
73
- }
74
- ALLOWED_GATE_MODES = {"profile", "explicit"}
75
-
76
- MAX_SHORT_FIELD = 240
77
- MAX_REASON = 500
78
- REVIEWER_RE = re.compile(r"^[A-Za-z0-9_.:@/-]+$")
79
- VALIDATION_EVIDENCE_RE = re.compile(
80
- r"(?im)^\s*(?:[-*]\s*)?validation"
81
- r"(?:\s+(?:commands?|results?|evidence))?\s*:\s*(\S[^\r\n]*)$"
82
- )
83
- ACCEPTANCE_EVIDENCE_RE = re.compile(
84
- r"(?im)^\s*(?:[-*]\s*)?(?:final\s+|user\s+)?acceptance"
85
- r"(?:\s+evidence)?\s*:\s*(\S[^\r\n]*)$"
86
- )
87
- ACCEPTED_BY_USER_RE = re.compile(
88
- r"(?im)^\s*(?:[-*]\s*)?accepted\s+by\s+user\s*:\s*(\S[^\r\n]*)$"
89
- )
90
- NO_DURABLE_LEARNING_RE = re.compile(r"(?i)\bno\s+durable\s+learning\b")
91
- DURABLE_LEARNING_EVIDENCE_RE = re.compile(
92
- r"(?im)^\s*(?:[-*]\s*)?(?:durable\s+learning(?:\s+decision)?|learning\s+decision|"
93
- r"spec\s+updates?|spec\s+update\s+(?:needed|evidence)|updated\s+spec|"
94
- r"retrospective(?:\.md)?|learning\s+artifact)\s*:\s*(\S[^\r\n]*)$"
95
- )
96
- INTEGRATION_EVIDENCE_RE = re.compile(
97
- r"(?im)^\s*(?:[-*]\s*)?(?:final\s+)?integration"
98
- r"(?:\s+evidence)?\s*:\s*(\S[^\r\n]*)$"
99
- )
100
- REVIEWED_CHANGE_SET_RE = re.compile(
101
- r"(?im)^\s*(?:[-*]\s*)?(?:reviewed\s+)?"
102
- r"(?:change[- ]set|changeset|diff|git\s+diff|ref|git\s+ref)"
103
- r"(?:\s+(?:identity|evidence|summary|ref))?\s*:\s*(\S[^\r\n]*)$"
104
- )
105
- CHECK_EVIDENCE_RE = re.compile(
106
- r"(?im)^\s*(?:[-*]\s*)?(?:check\s+evidence|trellis-check(?:\s+evidence)?)"
107
- r"\s*:\s*(\S[^\r\n]*)$"
108
- )
109
- PLACEHOLDER_VALUES_RE = re.compile(
110
- r"(?i)^(TBD|TODO|待定|待补充|N/?A|NA|NONE|-|\.\.\.)$"
111
- )
112
- PRD_ACCEPTANCE_CRITERIA_HEADING_RE = re.compile(
113
- r"(?im)^\s*#{1,6}\s*acceptance\s+criteria\s*$"
114
- )
115
- PRD_ACCEPTANCE_ITEM_RE = re.compile(
116
- r"(?im)^\s*-\s*\[[ xX]\]\s+(.+)$"
117
- )
118
- PRD_PLACEHOLDER_RE = re.compile(r"(?i)^\s*(TBD|TODO|待定|待补充)(?:\s*[.::])?\s*$")
119
- AUTO_PLANNING_REVIEWER = "trellis-cli"
120
- START_EXECUTION_AUTO_GATES = frozenset({"requirements-review", "architecture-review"})
121
-
122
- STABLE_TASK_KEYS = (
123
- "id",
124
- "name",
125
- "title",
126
- "description",
127
- "status",
128
- "dev_type",
129
- "scope",
130
- "package",
131
- "priority",
132
- "creator",
133
- "assignee",
134
- "parent",
135
- "children",
136
- "subtasks",
137
- "relatedFiles",
138
- "notes",
139
- "meta",
140
- "branch",
141
- "base_branch",
142
- "task_kind",
143
- "task_type",
144
- "kind",
145
- "mode",
146
- "contract_epoch",
147
- )
148
-
149
-
150
- @dataclass
151
- class GateGuardResult:
152
- """Result from a protected-transition guard."""
153
-
154
- ok: bool
155
- errors: list[str] = field(default_factory=list)
156
- warnings: list[str] = field(default_factory=list)
157
- contract_fingerprint: str = ""
158
- artifact_fingerprints: dict[str, str] = field(default_factory=dict)
159
- required_gates: list[str] = field(default_factory=list)
160
- baseline_record: dict | None = None
161
- is_full_task: bool = False
162
- closeout_profile: str = "lite"
163
- auto_gate_records: dict[str, dict] = field(default_factory=dict)
164
-
165
-
166
- def utc_now() -> str:
167
- """Return a compact UTC timestamp for task.json records."""
168
- return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace(
169
- "+00:00", "Z"
170
- )
171
-
172
-
173
- def normalize_result(result: str) -> str:
174
- """Normalize a CLI result value."""
175
- return result.strip().upper()
176
-
177
-
178
- def task_closeout_profile(task_dir: Path, task_data: dict | None = None) -> str:
179
- """Return lite, full, or parent closeout profile."""
180
- data = task_data or {}
181
- child_names = data.get("children")
182
- if isinstance(child_names, list) and any(isinstance(name, str) for name in child_names):
183
- return "parent"
184
-
185
- meta = data.get("meta") if isinstance(data.get("meta"), dict) else {}
186
- candidates = [
187
- data.get("task_kind"),
188
- data.get("task_type"),
189
- data.get("kind"),
190
- data.get("mode"),
191
- meta.get("task_kind"),
192
- meta.get("task_type"),
193
- meta.get("classification"),
194
- meta.get("mode"),
195
- ]
196
-
197
- explicit_lite = False
198
- for value in candidates:
199
- if not isinstance(value, str):
200
- continue
201
- normalized = value.lower().replace("_", "-")
202
- if "parent" in normalized:
203
- return "parent"
204
- if "full" in normalized:
205
- return "full"
206
- if "lite" in normalized:
207
- explicit_lite = True
208
-
209
- if explicit_lite:
210
- return "lite"
211
-
212
- if (task_dir / "design.md").is_file() and (task_dir / "implement.md").is_file():
213
- return "full"
214
- return "lite"
215
-
216
-
217
- def is_full_task(task_dir: Path, task_data: dict | None = None) -> bool:
218
- """Return True when a task should satisfy Full Task gates."""
219
- return task_closeout_profile(task_dir, task_data) == "full"
220
-
221
-
222
- def read_strategy_contract(task_dir: Path) -> tuple[dict, list[str]]:
223
- """Parse the lightweight Development Strategy Contract from implement.md."""
224
- implement_path = task_dir / "implement.md"
225
- if not implement_path.is_file():
226
- return {}, ["implement.md"]
227
-
228
- try:
229
- lines = implement_path.read_text(encoding="utf-8").splitlines()
230
- except OSError:
231
- return {}, ["implement.md could not be read"]
232
-
233
- contract: dict = {}
234
- section: str | None = None
235
- list_key: str | None = None
236
- started = False
237
-
238
- top_level_keys = {
239
- "execution_mode",
240
- "isolation",
241
- "verification_profile",
242
- "retrieval_profile",
243
- "optional_capabilities",
244
- "quality_gates",
245
- }
246
-
247
- for raw_line in lines:
248
- line = raw_line.rstrip()
249
- stripped = line.strip()
250
- if not stripped:
251
- continue
252
- if stripped.startswith("#") or stripped.startswith("```"):
253
- continue
254
-
255
- top_match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$", stripped)
256
- if top_match:
257
- key = top_match.group(1)
258
- value = top_match.group(2).strip()
259
- if key in top_level_keys:
260
- started = True
261
- section = key if key == "quality_gates" else None
262
- list_key = key if key == "optional_capabilities" and not value else None
263
- if key == "quality_gates":
264
- contract.setdefault("quality_gates", {})
265
- elif key == "optional_capabilities":
266
- if value:
267
- contract[key] = _parse_inline_list(value)
268
- else:
269
- contract[key] = []
270
- else:
271
- contract[key] = value
272
- continue
273
-
274
- if not started:
275
- continue
276
-
277
- nested_match = re.match(r"^\s+([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$", line)
278
- if nested_match and section == "quality_gates":
279
- key = nested_match.group(1)
280
- value = nested_match.group(2).strip()
281
- gates = contract.setdefault("quality_gates", {})
282
- if value:
283
- gates[key] = _parse_inline_list(value)
284
- list_key = None
285
- else:
286
- gates[key] = []
287
- list_key = f"quality_gates.{key}"
288
- continue
289
-
290
- item_match = re.match(r"^\s*-\s+(.+)$", line)
291
- if item_match:
292
- item = item_match.group(1).strip()
293
- if list_key == "optional_capabilities":
294
- contract.setdefault("optional_capabilities", []).append(item)
295
- elif list_key and list_key.startswith("quality_gates."):
296
- gate_key = list_key.split(".", 1)[1]
297
- gates = contract.setdefault("quality_gates", {})
298
- gates.setdefault(gate_key, []).append(item)
299
-
300
- errors = validate_strategy_contract(contract)
301
- return contract, errors
302
-
303
-
304
- def validate_strategy_contract(contract: dict) -> list[str]:
305
- """Validate the parsed Development Strategy Contract."""
306
- errors: list[str] = []
307
- required = [
308
- "execution_mode",
309
- "isolation",
310
- "verification_profile",
311
- "retrieval_profile",
312
- "optional_capabilities",
313
- "quality_gates",
314
- ]
315
- for key in required:
316
- if key not in contract:
317
- errors.append(f"Development Strategy Contract missing {key}")
318
-
319
- execution_mode = contract.get("execution_mode")
320
- if execution_mode and execution_mode not in ALLOWED_EXECUTION_MODES:
321
- errors.append(f"invalid execution_mode: {execution_mode}")
322
-
323
- isolation = contract.get("isolation")
324
- if isolation and isolation not in ALLOWED_ISOLATION:
325
- errors.append(f"invalid isolation: {isolation}")
326
-
327
- verification_profile = contract.get("verification_profile")
328
- if verification_profile and verification_profile not in ALLOWED_VERIFICATION_PROFILES:
329
- errors.append(f"invalid verification_profile: {verification_profile}")
330
-
331
- retrieval_profile = contract.get("retrieval_profile")
332
- if retrieval_profile and retrieval_profile not in ALLOWED_RETRIEVAL_PROFILES:
333
- errors.append(f"invalid retrieval_profile: {retrieval_profile}")
334
-
335
- optional_capabilities = contract.get("optional_capabilities")
336
- if "optional_capabilities" in contract and not isinstance(optional_capabilities, list):
337
- errors.append("optional_capabilities must be a list")
338
-
339
- gates = contract.get("quality_gates")
340
- if isinstance(gates, dict):
341
- errors.extend(_validate_quality_gates(gates, verification_profile))
342
- elif "quality_gates" in contract:
343
- errors.append("quality_gates must be a mapping")
344
-
345
- return errors
346
-
347
-
348
- def enabled_gates_for_contract(contract: dict) -> list[str]:
349
- """Return enabled non-baseline gates from a parsed contract."""
350
- gates = contract.get("quality_gates")
351
- if not isinstance(gates, dict):
352
- return []
353
-
354
- mode = gates.get("mode", "profile")
355
- if mode == "explicit":
356
- enabled = gates.get("enabled", [])
357
- return [g for g in enabled if isinstance(g, str)]
358
-
359
- profile = gates.get("profile") or contract.get("verification_profile") or "standard"
360
- return list(PROFILE_DEFAULT_GATES.get(profile, PROFILE_DEFAULT_GATES["standard"]))
361
-
362
-
363
- def required_gates_for_transition(transition: str, contract: dict) -> list[str]:
364
- """Return non-baseline gates required before a transition."""
365
- enabled = set(enabled_gates_for_contract(contract))
366
-
367
- if transition == "start-execution":
368
- required = ["requirements-review"]
369
- if "architecture-review" in enabled:
370
- required.append("architecture-review")
371
- return required
372
-
373
- if transition in ("full-task-complete", "child-review"):
374
- required = ["code-review"]
375
- if "architecture-review" in enabled:
376
- required.append("architecture-review")
377
- if "architecture-deep-review" in enabled:
378
- required.append("architecture-deep-review")
379
- return required
380
-
381
- if transition == "parent-integrated":
382
- return ["integration-review"]
383
-
384
- return []
385
-
386
-
387
- def compute_contract_fingerprint(
388
- task_dir: Path,
389
- task_data: dict,
390
- contract: dict | None = None,
391
- ) -> str:
392
- """Compute a task-level fingerprint excluding generated result fields."""
393
- payload = {
394
- "schema_version": SCHEMA_VERSION,
395
- "task_dir": task_dir.name,
396
- "stable_task": _stable_task_data(task_data),
397
- "strategy_contract": contract or {},
398
- }
399
- return _hash_payload(payload)
400
-
401
-
402
- def compute_artifact_fingerprint(
403
- task_dir: Path,
404
- task_data: dict,
405
- transition: str,
406
- gate: str,
407
- ) -> str:
408
- """Compute a transition/gate-scoped artifact fingerprint."""
409
- files = _artifact_files_for(transition, gate)
410
- payload = {
411
- "schema_version": SCHEMA_VERSION,
412
- "transition": transition,
413
- "gate": gate,
414
- "task_dir": task_dir.name,
415
- "stable_task": _stable_task_data(task_data),
416
- "parent_contract": _parent_contract_metadata(
417
- task_dir,
418
- task_data,
419
- transition,
420
- ),
421
- "reviewed_change_set": _reviewed_change_set_metadata(
422
- task_dir,
423
- transition,
424
- ),
425
- "files": _read_artifact_files(task_dir, files),
426
- }
427
- return _hash_payload(payload)
428
-
429
-
430
- def make_baseline_record(
431
- task_dir: Path,
432
- task_data: dict,
433
- transition: str,
434
- contract: dict | None = None,
435
- evidence: str = "task.json",
436
- ) -> dict:
437
- """Build a CLI-owned baseline-check PASS record."""
438
- contract_fingerprint = compute_contract_fingerprint(task_dir, task_data, contract)
439
- artifact_fingerprint = compute_artifact_fingerprint(
440
- task_dir, task_data, transition, BASELINE_GATE
441
- )
442
- return {
443
- "schema_version": SCHEMA_VERSION,
444
- "transition": transition,
445
- "gate": BASELINE_GATE,
446
- "result": "PASS",
447
- "reviewer": "trellis-cli",
448
- "evidence": evidence,
449
- "checked_at": utc_now(),
450
- "contract_fingerprint": contract_fingerprint,
451
- "artifact_fingerprint": artifact_fingerprint,
452
- "issue_fingerprint": None,
453
- "consecutive_failures": 0,
454
- "approved_skip": None,
455
- }
456
-
457
-
458
- def write_gate_record(task_data: dict, transition: str, gate: str, record: dict) -> None:
459
- """Write a gate record into task_data in-place."""
460
- qgr = task_data.get("quality_gate_results")
461
- if not isinstance(qgr, dict):
462
- qgr = {}
463
- task_data["quality_gate_results"] = qgr
464
-
465
- qgr["schema_version"] = SCHEMA_VERSION
466
- qgr["contract_fingerprint"] = record.get("contract_fingerprint")
467
- qgr["artifact_fingerprint"] = record.get("artifact_fingerprint")
468
- transitions = qgr.get("transitions")
469
- if not isinstance(transitions, dict):
470
- transitions = {}
471
- qgr["transitions"] = transitions
472
-
473
- transition_records = transitions.get(transition)
474
- if not isinstance(transition_records, dict):
475
- transition_records = {}
476
- transitions[transition] = transition_records
477
- transition_records[gate] = record
478
-
479
-
480
- def build_reviewer_gate_record(
481
- task_dir: Path,
482
- task_data: dict,
483
- transition: str,
484
- gate: str,
485
- result: str,
486
- reviewer: str,
487
- evidence: str,
488
- issue_fingerprint: str | None = None,
489
- issue_summary: str | None = None,
490
- root_cause: str | None = None,
491
- skip_approved_by: str | None = None,
492
- skip_reason: str | None = None,
493
- contract_fingerprint: str | None = None,
494
- artifact_fingerprint: str | None = None,
495
- ) -> tuple[dict | None, list[str], list[str]]:
496
- """Validate and build a non-baseline reviewer gate record."""
497
- normalized_result = normalize_result(result)
498
- errors = validate_reviewer_gate_input(
499
- transition=transition,
500
- gate=gate,
501
- result=normalized_result,
502
- reviewer=reviewer,
503
- evidence=evidence,
504
- issue_fingerprint=issue_fingerprint,
505
- issue_summary=issue_summary,
506
- root_cause=root_cause,
507
- skip_approved_by=skip_approved_by,
508
- skip_reason=skip_reason,
509
- )
510
- warnings: list[str] = []
511
- if errors:
512
- return None, errors, warnings
513
-
514
- if normalized_result in ("PASS", "SKIPPED"):
515
- errors.extend(
516
- validate_transition_readiness(
517
- task_dir,
518
- task_data,
519
- transition,
520
- gate=gate,
521
- mode="record",
522
- )
523
- )
524
- if errors:
525
- return None, errors, warnings
526
-
527
- contract, contract_errors = read_strategy_contract(task_dir) if is_full_task(task_dir, task_data) else ({}, [])
528
- if contract_errors:
529
- errors.extend(contract_errors)
530
- return None, errors, warnings
531
-
532
- current_contract_fingerprint = compute_contract_fingerprint(
533
- task_dir, task_data, contract
534
- )
535
- current_artifact_fingerprint = compute_artifact_fingerprint(
536
- task_dir, task_data, transition, gate
537
- )
538
- if contract_fingerprint and contract_fingerprint != current_contract_fingerprint:
539
- errors.append("provided contract fingerprint does not match current task artifacts")
540
- if artifact_fingerprint and artifact_fingerprint != current_artifact_fingerprint:
541
- errors.append("provided artifact fingerprint does not match current task artifacts")
542
- if errors:
543
- return None, errors, warnings
544
-
545
- previous = _get_gate_record(task_data, transition, gate)
546
- consecutive_failures = 0
547
- required_user_choice = None
548
- if normalized_result == "FAIL":
549
- consecutive_failures = _next_consecutive_failures(previous, issue_fingerprint)
550
- if consecutive_failures > 3:
551
- required_user_choice = {
552
- "required": True,
553
- "reason": "same gate and issue fingerprint failed more than three times",
554
- "options": [
555
- "re-plan",
556
- "continue-fixing",
557
- "user-approved-skip-if-allowed",
558
- ],
559
- }
560
- warnings.append(
561
- "same gate and issue fingerprint has failed more than three times; ask the user to choose re-plan, continue fixing, or user-approved skip if allowed"
562
- )
563
-
564
- approved_skip = None
565
- if normalized_result == "SKIPPED":
566
- approved_skip = {
567
- "approved_by": skip_approved_by,
568
- "reason": skip_reason,
569
- "approved_at": utc_now(),
570
- }
571
-
572
- record = {
573
- "schema_version": SCHEMA_VERSION,
574
- "transition": transition,
575
- "gate": gate,
576
- "result": normalized_result,
577
- "reviewer": reviewer,
578
- "evidence": evidence,
579
- "checked_at": utc_now(),
580
- "contract_fingerprint": current_contract_fingerprint,
581
- "artifact_fingerprint": current_artifact_fingerprint,
582
- "issue_fingerprint": issue_fingerprint if normalized_result == "FAIL" else None,
583
- "root_cause": root_cause if normalized_result == "FAIL" else None,
584
- "route": _route_for_fail_root_cause(root_cause) if normalized_result == "FAIL" else None,
585
- "consecutive_failures": consecutive_failures,
586
- "required_user_choice": required_user_choice,
587
- "approved_skip": approved_skip,
588
- }
589
- if issue_summary and normalized_result == "FAIL":
590
- record["issue_summary"] = issue_summary
591
- return record, [], warnings
592
-
593
-
594
- def validate_start_execution(
595
- task_dir: Path,
596
- task_data: dict | None,
597
- approved: bool,
598
- ) -> GateGuardResult:
599
- """Validate start-execution readiness."""
600
- errors: list[str] = []
601
- if task_data is None:
602
- return GateGuardResult(ok=False, errors=["task.json"])
603
-
604
- status = task_data.get("status")
605
- if status not in ("planning", "in_progress"):
606
- errors.append(f"task status must be planning or in_progress, got {status!r}")
607
-
608
- errors.extend(_required_file_errors(task_dir, ["prd.md"]))
609
-
610
- has_design = (task_dir / "design.md").is_file()
611
- has_implement = (task_dir / "implement.md").is_file()
612
- if has_design != has_implement:
613
- errors.append("design.md and implement.md must be present together for Full Tasks")
614
-
615
- full_task = is_full_task(task_dir, task_data)
616
- contract: dict = {}
617
- required_gates: list[str] = []
618
- if full_task:
619
- contract, contract_errors = read_strategy_contract(task_dir)
620
- errors.extend(contract_errors)
621
- if not contract_errors:
622
- required_gates = required_gates_for_transition("start-execution", contract)
623
-
624
- contract_fingerprint = compute_contract_fingerprint(task_dir, task_data, contract)
625
- artifact_fingerprints = {
626
- BASELINE_GATE: compute_artifact_fingerprint(
627
- task_dir, task_data, "start-execution", BASELINE_GATE
628
- )
629
- }
630
-
631
- auto_gate_records: dict[str, dict] = {}
632
- for gate in required_gates:
633
- artifact_fingerprints[gate] = compute_artifact_fingerprint(
634
- task_dir, task_data, "start-execution", gate
635
- )
636
- gate_errors = _validate_gate_record_for_transition(
637
- task_data=task_data,
638
- transition="start-execution",
639
- gate=gate,
640
- contract_fingerprint=contract_fingerprint,
641
- artifact_fingerprint=artifact_fingerprints[gate],
642
- )
643
- if gate_errors and gate in START_EXECUTION_AUTO_GATES and _only_missing_gate_record(
644
- gate_errors
645
- ):
646
- readiness_errors = _start_execution_planning_gate_readiness_errors(
647
- task_dir,
648
- gate,
649
- full_task=full_task,
650
- contract=contract,
651
- )
652
- if readiness_errors:
653
- errors.extend(readiness_errors)
654
- else:
655
- auto_gate_records[gate] = make_planning_review_gate_record(
656
- task_dir=task_dir,
657
- task_data=task_data,
658
- transition="start-execution",
659
- gate=gate,
660
- contract=contract,
661
- contract_fingerprint=contract_fingerprint,
662
- artifact_fingerprint=artifact_fingerprints[gate],
663
- )
664
- else:
665
- errors.extend(gate_errors)
666
-
667
- if approved:
668
- errors.extend(
669
- _validate_existing_execution_approval(
670
- task_data=task_data,
671
- contract_fingerprint=contract_fingerprint,
672
- artifact_fingerprint=artifact_fingerprints[BASELINE_GATE],
673
- )
674
- )
675
-
676
- if not approved:
677
- errors.append("--approved is required for mutation")
678
-
679
- baseline_record = None
680
- if not errors:
681
- baseline_record = make_baseline_record(
682
- task_dir, task_data, "start-execution", contract
683
- )
684
-
685
- return GateGuardResult(
686
- ok=not errors,
687
- errors=errors,
688
- contract_fingerprint=contract_fingerprint,
689
- artifact_fingerprints=artifact_fingerprints,
690
- required_gates=required_gates,
691
- baseline_record=baseline_record,
692
- is_full_task=full_task,
693
- auto_gate_records=auto_gate_records,
694
- )
695
-
696
-
697
- def _validate_existing_execution_approval(
698
- task_data: dict,
699
- contract_fingerprint: str,
700
- artifact_fingerprint: str,
701
- ) -> list[str]:
702
- """Reject stale approval state before re-approving an in-progress task."""
703
- approval = task_data.get("execution_approval")
704
- if not isinstance(approval, dict):
705
- return []
706
-
707
- errors: list[str] = []
708
- if approval.get("transition") != "start-execution":
709
- errors.append("stale execution approval: transition mismatch")
710
- if approval.get("contract_fingerprint") != contract_fingerprint:
711
- errors.append("stale execution approval: contract fingerprint changed")
712
- if approval.get("artifact_fingerprint") != artifact_fingerprint:
713
- errors.append("stale execution approval: artifact fingerprint changed")
714
- if errors:
715
- errors.append(
716
- "return to Planning, refresh gates, run start-execution --check, and ask for explicit approval again"
717
- )
718
- return errors
719
-
720
-
721
- def validate_start_execution_check(
722
- task_dir: Path,
723
- task_data: dict | None,
724
- ) -> GateGuardResult:
725
- """Validate start-execution preflight without requiring approval."""
726
- result = validate_start_execution(task_dir, task_data, approved=True)
727
- return result
728
-
729
-
730
- def validate_transition_readiness(
731
- task_dir: Path,
732
- task_data: dict | None,
733
- transition: str,
734
- *,
735
- gate: str | None = None,
736
- mode: str = "record",
737
- ) -> list[str]:
738
- """Validate transition evidence and, in complete mode, required gate records."""
739
- if task_data is None:
740
- return ["task.json"]
741
-
742
- errors: list[str] = []
743
- if transition not in KNOWN_TRANSITIONS:
744
- errors.append(f"unknown transition: {transition}")
745
- return errors
746
-
747
- profile = task_closeout_profile(task_dir, task_data)
748
-
749
- if mode == "record":
750
- if not gate:
751
- errors.append("gate is required for record readiness checks")
752
- return errors
753
- required_signals = _evidence_requirements_for_gate(transition, gate)
754
- errors.extend(_evidence_errors_for_signals(task_dir, task_data, required_signals))
755
- return errors
756
-
757
- if transition == "child-review":
758
- if profile != "full":
759
- return errors
760
- contract, contract_errors = read_strategy_contract(task_dir)
761
- errors.extend(contract_errors)
762
- if contract_errors:
763
- return errors
764
- return errors + _complete_transition_gate_errors(
765
- task_dir,
766
- task_data,
767
- transition,
768
- contract,
769
- required_gates_for_transition(transition, contract),
770
- )
771
-
772
- if transition == "full-task-complete":
773
- if profile != "full":
774
- return errors
775
- contract, contract_errors = read_strategy_contract(task_dir)
776
- errors.extend(contract_errors)
777
- if contract_errors:
778
- return errors
779
- return errors + _complete_transition_gate_errors(
780
- task_dir,
781
- task_data,
782
- transition,
783
- contract,
784
- required_gates_for_transition(transition, contract),
785
- )
786
-
787
- if transition == "parent-integrated":
788
- if profile != "parent":
789
- return errors
790
- child_names = task_data.get("children")
791
- if isinstance(child_names, list):
792
- errors.extend(
793
- validate_parent_children_complete(
794
- task_dir,
795
- [name for name in child_names if isinstance(name, str)],
796
- )
797
- )
798
- errors.extend(
799
- _evidence_errors_for_signals(task_dir, task_data, ["integration"])
800
- )
801
- contract: dict = {}
802
- if (task_dir / "implement.md").is_file():
803
- contract, contract_errors = read_strategy_contract(task_dir)
804
- errors.extend(contract_errors)
805
- if contract_errors:
806
- return errors
807
- return errors + _complete_transition_gate_errors(
808
- task_dir,
809
- task_data,
810
- transition,
811
- contract,
812
- required_gates_for_transition(transition, contract),
813
- )
814
-
815
- return errors
816
-
817
-
818
- def _complete_transition_gate_errors(
819
- task_dir: Path,
820
- task_data: dict,
821
- transition: str,
822
- contract: dict,
823
- required_gates: list[str],
824
- ) -> list[str]:
825
- errors: list[str] = []
826
- contract_fingerprint = compute_contract_fingerprint(task_dir, task_data, contract)
827
- for gate_name in required_gates:
828
- artifact_fingerprint = compute_artifact_fingerprint(
829
- task_dir, task_data, transition, gate_name
830
- )
831
- errors.extend(
832
- _validate_gate_record_for_transition(
833
- task_data=task_data,
834
- transition=transition,
835
- gate=gate_name,
836
- contract_fingerprint=contract_fingerprint,
837
- artifact_fingerprint=artifact_fingerprint,
838
- )
839
- )
840
- errors.extend(
841
- _evidence_errors_for_signals(
842
- task_dir,
843
- task_data,
844
- _evidence_requirements_for_gate(transition, gate_name),
845
- )
846
- )
847
- return errors
848
-
849
-
850
- def _evidence_requirements_for_gate(transition: str, gate: str) -> list[str]:
851
- if gate == "code-review" and transition in ("full-task-complete", "child-review"):
852
- return ["validation", "check_evidence", "reviewed_change_set"]
853
- if gate == "integration-review" and transition == "parent-integrated":
854
- return ["integration"]
855
- if gate in ("architecture-review", "architecture-deep-review") and transition in (
856
- "full-task-complete",
857
- "child-review",
858
- ):
859
- return ["validation", "check_evidence"]
860
- return []
861
-
862
-
863
- def _evidence_errors_for_signals(
864
- task_dir: Path,
865
- task_data: dict,
866
- required_signals: list[str],
867
- ) -> list[str]:
868
- status = verify_evidence_status(task_dir, task_data)
869
- label_map = {
870
- "validation": "verify.md missing substantive validation evidence",
871
- "check_evidence": "verify.md missing check evidence",
872
- "acceptance": "verify.md missing final acceptance evidence",
873
- "durable_learning": "verify.md missing durable-learning decision evidence",
874
- "integration": "verify.md missing final integration evidence",
875
- "reviewed_change_set": (
876
- "verify.md or handoff.md missing reviewed change-set evidence"
877
- ),
878
- }
879
- errors: list[str] = []
880
- for signal in required_signals:
881
- if not status.get(signal):
882
- errors.append(label_map.get(signal, f"missing evidence signal: {signal}"))
883
- return errors
884
-
885
-
886
- def verify_evidence_status(task_dir: Path, task_data: dict) -> dict[str, bool]:
887
- """Return substantive evidence signals from verify.md and handoff.md."""
888
- verify_path = task_dir / "verify.md"
889
- content = ""
890
- if verify_path.is_file():
891
- try:
892
- content = verify_path.read_text(encoding="utf-8")
893
- except OSError:
894
- content = ""
895
-
896
- handoff_path = task_dir / "handoff.md"
897
- handoff_content = ""
898
- if handoff_path.is_file():
899
- try:
900
- handoff_content = handoff_path.read_text(encoding="utf-8")
901
- except OSError:
902
- handoff_content = ""
903
-
904
- child_names = task_data.get("children")
905
- has_children = isinstance(child_names, list) and bool(child_names)
906
- return {
907
- "validation": _has_substantive_validation_evidence(content),
908
- "check_evidence": _has_substantive_check_evidence(content),
909
- "acceptance": _has_substantive_acceptance_evidence(content),
910
- "durable_learning": _has_durable_learning_evidence(content),
911
- "integration": (
912
- _has_substantive_integration_evidence(content) if has_children else True
913
- ),
914
- "reviewed_change_set": (
915
- _has_substantive_reviewed_change_set(content)
916
- or _has_substantive_reviewed_change_set(handoff_content)
917
- ),
918
- }
919
-
920
-
921
- def validate_archive(
922
- task_dir: Path,
923
- task_data: dict | None,
924
- ) -> GateGuardResult:
925
- """Validate archive readiness without mutating task files."""
926
- errors: list[str] = []
927
- if task_data is None:
928
- return GateGuardResult(ok=False, errors=["task.json"])
929
-
930
- errors.extend(_required_file_errors(task_dir, ["verify.md"]))
931
- errors.extend(_verify_evidence_errors(task_dir, task_data))
932
-
933
- parent_name = task_data.get("parent")
934
- if isinstance(parent_name, str) and parent_name:
935
- parent_dir = task_dir.parent / parent_name
936
- if not parent_dir.is_dir():
937
- errors.append(f"parent task not found: {parent_name}")
938
- else:
939
- errors.extend(validate_child_archive_state(parent_dir, task_dir.name))
940
- if get_child_state(parent_dir, task_dir.name) == "integrated":
941
- errors.extend(_required_file_errors(task_dir, ["handoff.md"]))
942
-
943
- child_names = task_data.get("children")
944
- if isinstance(child_names, list) and child_names:
945
- errors.extend(
946
- validate_parent_children_complete(
947
- task_dir,
948
- [name for name in child_names if isinstance(name, str)],
949
- )
950
- )
951
-
952
- profile = task_closeout_profile(task_dir, task_data)
953
- contract: dict = {}
954
- required_gates: list[str] = []
955
- artifact_fingerprints: dict[str, str] = {}
956
- baseline_record = None
957
-
958
- if profile == "full":
959
- contract, contract_errors = read_strategy_contract(task_dir)
960
- errors.extend(contract_errors)
961
- if not contract_errors:
962
- required_gates = required_gates_for_transition(
963
- "full-task-complete", contract
964
- )
965
- errors.extend(
966
- validate_transition_readiness(
967
- task_dir,
968
- task_data,
969
- "full-task-complete",
970
- mode="complete",
971
- )
972
- )
973
- elif profile == "parent":
974
- errors.extend(
975
- validate_transition_readiness(
976
- task_dir,
977
- task_data,
978
- "parent-integrated",
979
- mode="complete",
980
- )
981
- )
982
- required_gates = required_gates_for_transition("parent-integrated", contract)
983
-
984
- contract_fingerprint = compute_contract_fingerprint(task_dir, task_data, contract)
985
-
986
- if profile == "full" and not errors:
987
- artifact_fingerprints[BASELINE_GATE] = compute_artifact_fingerprint(
988
- task_dir, task_data, "full-task-complete", BASELINE_GATE
989
- )
990
- for gate in required_gates:
991
- artifact_fingerprints[gate] = compute_artifact_fingerprint(
992
- task_dir, task_data, "full-task-complete", gate
993
- )
994
- baseline_record = make_baseline_record(
995
- task_dir, task_data, "full-task-complete", contract
996
- )
997
-
998
- return GateGuardResult(
999
- ok=not errors,
1000
- errors=errors,
1001
- contract_fingerprint=contract_fingerprint,
1002
- artifact_fingerprints=artifact_fingerprints,
1003
- required_gates=required_gates,
1004
- baseline_record=baseline_record,
1005
- is_full_task=profile == "full",
1006
- closeout_profile=profile,
1007
- )
1008
-
1009
-
1010
- def _verify_evidence_errors(task_dir: Path, task_data: dict) -> list[str]:
1011
- """Validate human-readable archive evidence in verify.md."""
1012
- verify_path = task_dir / "verify.md"
1013
- if not verify_path.is_file():
1014
- return []
1015
-
1016
- try:
1017
- verify_path.read_text(encoding="utf-8")
1018
- except OSError:
1019
- return ["verify.md could not be read for archive evidence"]
1020
-
1021
- errors: list[str] = []
1022
- status = verify_evidence_status(task_dir, task_data)
1023
- if not status["validation"]:
1024
- errors.append("verify.md missing validation evidence")
1025
- if not status["acceptance"]:
1026
- errors.append("verify.md missing final acceptance evidence")
1027
- if not status["durable_learning"]:
1028
- errors.append("verify.md missing durable-learning decision evidence")
1029
- if not status["integration"]:
1030
- errors.append("verify.md missing final integration evidence")
1031
-
1032
- return errors
1033
-
1034
-
1035
- def _is_substantive_evidence(value: str | None) -> bool:
1036
- if not value or not isinstance(value, str):
1037
- return False
1038
- stripped = value.strip()
1039
- if len(stripped) < 3:
1040
- return False
1041
- return not PLACEHOLDER_VALUES_RE.match(stripped)
1042
-
1043
-
1044
- def _has_substantive_line_match(content: str, pattern: re.Pattern[str]) -> bool:
1045
- if not content:
1046
- return False
1047
- for match in pattern.finditer(content):
1048
- if match.lastindex and match.lastindex >= 1:
1049
- if _is_substantive_evidence(match.group(1)):
1050
- return True
1051
- continue
1052
- line = match.group(0)
1053
- if ":" in line:
1054
- value = line.split(":", 1)[1].strip()
1055
- if _is_substantive_evidence(value):
1056
- return True
1057
- return False
1058
-
1059
-
1060
- def _has_substantive_validation_evidence(content: str) -> bool:
1061
- return _has_substantive_line_match(content, VALIDATION_EVIDENCE_RE)
1062
-
1063
-
1064
- def _has_substantive_check_evidence(content: str) -> bool:
1065
- return _has_substantive_line_match(content, CHECK_EVIDENCE_RE)
1066
-
1067
-
1068
- def _has_substantive_acceptance_evidence(content: str) -> bool:
1069
- return (
1070
- _has_substantive_line_match(content, ACCEPTANCE_EVIDENCE_RE)
1071
- or _has_substantive_line_match(content, ACCEPTED_BY_USER_RE)
1072
- )
1073
-
1074
-
1075
- def _has_substantive_integration_evidence(content: str) -> bool:
1076
- return _has_substantive_line_match(content, INTEGRATION_EVIDENCE_RE)
1077
-
1078
-
1079
- def _has_substantive_reviewed_change_set(content: str) -> bool:
1080
- if not content:
1081
- return False
1082
- for match in REVIEWED_CHANGE_SET_RE.finditer(content):
1083
- if _is_substantive_evidence(match.group(1)):
1084
- return True
1085
- return False
1086
-
1087
-
1088
- def _has_validation_evidence(content: str) -> bool:
1089
- return _has_substantive_validation_evidence(content)
1090
-
1091
-
1092
- def _has_final_acceptance_evidence(content: str) -> bool:
1093
- return _has_substantive_acceptance_evidence(content)
1094
-
1095
-
1096
- def _has_durable_learning_evidence(content: str) -> bool:
1097
- return bool(
1098
- NO_DURABLE_LEARNING_RE.search(content)
1099
- or _has_substantive_line_match(content, DURABLE_LEARNING_EVIDENCE_RE)
1100
- )
1101
-
1102
-
1103
- def durable_learning_decision_status(content: str) -> dict[str, bool]:
1104
- """Return which durable-learning outcomes are signaled in verify.md text."""
1105
- return {
1106
- "no_durable_learning": bool(NO_DURABLE_LEARNING_RE.search(content)),
1107
- "spec_update": bool(
1108
- re.search(
1109
- r"(?im)^\s*(?:[-*]\s*)?(?:spec\s+updates?|spec\s+update\s+"
1110
- r"(?:needed|evidence)|updated\s+spec)\s*:\s*\S",
1111
- content,
1112
- )
1113
- ),
1114
- "learning_artifact": bool(
1115
- re.search(
1116
- r"(?im)^\s*(?:[-*]\s*)?(?:learning\s+artifact|retrospective(?:\.md)?)\s*:\s*\S",
1117
- content,
1118
- )
1119
- or re.search(
1120
- r"(?im)^\s*(?:[-*]\s*)?(?:durable\s+learning|learning\s+decision)\s*:\s*\S",
1121
- content,
1122
- )
1123
- ),
1124
- "any": _has_durable_learning_evidence(content),
1125
- }
1126
-
1127
-
1128
- def suggest_spec_targets(repo_root: Path, task_dir: Path, task_data: dict) -> list[str]:
1129
- """Suggest existing spec paths from task scope; does not invent new rules."""
1130
- suggestions: list[str] = []
1131
- spec_root = repo_root / ".trellis" / "spec"
1132
- if not spec_root.is_dir():
1133
- return suggestions
1134
-
1135
- package = task_data.get("package")
1136
- if isinstance(package, str) and package.strip():
1137
- pkg_index = spec_root / package.strip() / "index.md"
1138
- if pkg_index.is_file():
1139
- suggestions.append(f".trellis/spec/{package.strip()}/index.md")
1140
-
1141
- scope = task_data.get("scope")
1142
- if isinstance(scope, str) and scope.strip():
1143
- scope_parts = [p for p in scope.strip().replace("\\", "/").split("/") if p]
1144
- scope_path = spec_root.joinpath(*scope_parts) if scope_parts else spec_root
1145
- if scope_path.is_dir():
1146
- index = scope_path / "index.md"
1147
- if index.is_file():
1148
- suggestions.append(
1149
- f".trellis/spec/{scope_path.relative_to(spec_root).as_posix()}/index.md"
1150
- )
1151
-
1152
- guide_paths = (
1153
- "guides/durable-learning-decision-guide.md",
1154
- "guides/index.md",
1155
- )
1156
- for rel in guide_paths:
1157
- path = spec_root / rel
1158
- if path.is_file():
1159
- suggestions.append(f".trellis/spec/{rel}")
1160
-
1161
- return _dedupe_preserve_order(suggestions)[:6]
1162
-
1163
-
1164
- def build_spec_update_scaffold(
1165
- repo_root: Path,
1166
- task_dir: Path,
1167
- task_data: dict,
1168
- *,
1169
- trigger: str | None = None,
1170
- ) -> str:
1171
- """Markdown checklist for spec capture; user/reviewer must confirm before editing specs."""
1172
- targets = suggest_spec_targets(repo_root, task_dir, task_data)
1173
- rel_task = f".trellis/tasks/{task_dir.name}"
1174
- lines = [
1175
- "## Spec update scaffold (reviewer-confirmed)",
1176
- "",
1177
- "_Suggestions only — do not treat this block as project policy until a human confirms._",
1178
- "",
1179
- ]
1180
- if trigger:
1181
- lines.extend([f"Trigger: {trigger.strip()}", ""])
1182
- lines.extend(
1183
- [
1184
- "1. Decide outcome in verify.md:",
1185
- " - Routine: `Durable learning decision: no durable learning`",
1186
- " - Reusable insight: `Spec update evidence: .trellis/spec/<path>` after edits",
1187
- f" - Already documented: `Learning artifact: {rel_task}/handoff.md`",
1188
- "",
1189
- "2. Use `/trellis:update-spec` or `/trellis:break-loop` for depth; never auto-write specs.",
1190
- "",
1191
- ]
1192
- )
1193
- if targets:
1194
- lines.append("3. Existing spec indexes to consider (from task scope):")
1195
- for target in targets:
1196
- lines.append(f" - `{target}`")
1197
- lines.append("")
1198
- else:
1199
- lines.append(
1200
- "3. Browse `.trellis/spec/<package-or-layer>/index.md` for the right code-spec file."
1201
- )
1202
- lines.append("")
1203
- lines.append(
1204
- "4. Re-run `python ./.trellis/scripts/task.py archive <task> --check` after verify.md is final."
1205
- )
1206
- lines.append("")
1207
- return "\n".join(lines)
1208
-
1209
-
1210
- def _learning_decision_draft_lines(task_dir: Path, task_data: dict) -> list[str]:
1211
- """Default durable-learning block for prepare-archive-evidence."""
1212
- repo_root = task_dir.parent.parent.parent
1213
- targets = suggest_spec_targets(repo_root, task_dir, task_data)
1214
- target_hint = targets[0] if targets else ".trellis/spec/<layer>/index.md"
1215
- return [
1216
- "Durable learning decision: no durable learning for this task scope.",
1217
- "",
1218
- "# Replace the line above with ONE of these before archive:",
1219
- f"# Spec update evidence: {target_hint}",
1220
- f"# Learning artifact: .trellis/tasks/{task_dir.name}/handoff.md",
1221
- "# Spec update needed: (brief reason) — then run /trellis:update-spec and point Spec update evidence at the edited file",
1222
- "",
1223
- ]
1224
-
1225
-
1226
- ARCHIVE_EVIDENCE_DRAFT_MARKER = "<!-- trellis:archive-evidence-draft -->"
1227
-
1228
-
1229
- def _verify_evidence_status(task_dir: Path, task_data: dict) -> dict[str, bool]:
1230
- """Return which archive evidence sections are present in verify.md."""
1231
- status = verify_evidence_status(task_dir, task_data)
1232
- return {
1233
- "validation": status["validation"],
1234
- "acceptance": status["acceptance"],
1235
- "durable_learning": status["durable_learning"],
1236
- "integration": status["integration"],
1237
- }
1238
-
1239
-
1240
- def archive_repair_hints(
1241
- errors: list[str],
1242
- task_dir: Path,
1243
- task_data: dict,
1244
- guard: GateGuardResult,
1245
- ) -> list[str]:
1246
- """Map archive validation errors to actionable next-step hints."""
1247
- hints: list[str] = []
1248
- task_ref = task_dir.name
1249
- rel_task = f".trellis/tasks/{task_ref}"
1250
-
1251
- for error in errors:
1252
- if error == "verify.md":
1253
- hints.append(
1254
- f"Create verify.md, then run: python ./.trellis/scripts/task.py "
1255
- f"prepare-archive-evidence {rel_task}"
1256
- )
1257
- continue
1258
- if error == "verify.md missing validation evidence":
1259
- hints.append(
1260
- "Add a grep-friendly line such as "
1261
- "'Validation commands: <command> — <outcome>' to verify.md, "
1262
- f"or run: python ./.trellis/scripts/task.py prepare-archive-evidence {rel_task}"
1263
- )
1264
- continue
1265
- if error == "verify.md missing final acceptance evidence":
1266
- hints.append(
1267
- "Add 'Final acceptance evidence: <criteria met>' or "
1268
- "'Accepted by user: <who/when>' to verify.md, "
1269
- f"or run prepare-archive-evidence {rel_task}"
1270
- )
1271
- continue
1272
- if error == "verify.md missing durable-learning decision evidence":
1273
- hints.append(
1274
- "Durable learning decision (pick one grep-friendly line in verify.md): "
1275
- "'Durable learning decision: no durable learning' for routine work; "
1276
- "'Spec update evidence: .trellis/spec/<path>' after /trellis:update-spec; "
1277
- "'Learning artifact: <path>' when handoff/retrospective already captures the insight. "
1278
- f"Or run: python ./.trellis/scripts/task.py prepare-archive-evidence {rel_task}"
1279
- )
1280
- continue
1281
- if error == "verify.md missing final integration evidence":
1282
- hints.append(
1283
- "Parent tasks need 'Final integration evidence: <child handoffs / task-map>'. "
1284
- f"Run prepare-archive-evidence {rel_task} to draft a section from task-map.md"
1285
- )
1286
- continue
1287
- if error.startswith("missing gate record:"):
1288
- _rest = error.split(":", 1)[1].strip()
1289
- _transition, gate = _rest.split("/", 1)
1290
- transition = _transition.strip()
1291
- if transition == "parent-integrated":
1292
- hints.append(
1293
- f"Parent archive needs record-gate for parent-integrated/{gate}: "
1294
- f"python ./.trellis/scripts/task.py record-gate {rel_task} "
1295
- f"--transition parent-integrated --gate {gate} --result PASS "
1296
- f"--reviewer parent --evidence task-map.md"
1297
- )
1298
- elif transition == "child-review":
1299
- hints.append(
1300
- f"Full Child acceptance needs record-gate for child-review/{gate}: "
1301
- f"python ./.trellis/scripts/task.py record-gate {rel_task} "
1302
- f"--transition child-review --gate {gate} --result PASS "
1303
- f"--reviewer parent --evidence verify.md"
1304
- )
1305
- else:
1306
- hints.append(
1307
- f"Record reviewer gate after explicit review (never auto-PASS): "
1308
- f"python ./.trellis/scripts/task.py record-gate {rel_task} "
1309
- f"--transition {transition} --gate {gate} --result PASS "
1310
- f"--reviewer <reviewer-id> --evidence verify.md"
1311
- )
1312
- continue
1313
- if error == "verify.md missing check evidence":
1314
- hints.append(
1315
- "Add a grep-friendly line such as "
1316
- "'Check evidence: <trellis-check summary or manual review notes>' to verify.md"
1317
- )
1318
- continue
1319
- if error == (
1320
- "verify.md or handoff.md missing reviewed change-set evidence"
1321
- ):
1322
- hints.append(
1323
- "Add 'Reviewed change-set: <git ref or diff summary>' to verify.md or handoff.md"
1324
- )
1325
- continue
1326
- if error == "verify.md missing substantive validation evidence":
1327
- hints.append(
1328
- "Replace placeholder validation lines with substantive command/outcome text in verify.md"
1329
- )
1330
- continue
1331
- if error.startswith("gate failed:"):
1332
- hints.append(
1333
- "Resolve the FAIL gate (fix, re-review, or user-approved SKIPPED) "
1334
- "before archive."
1335
- )
1336
- continue
1337
- if error.startswith("stale "):
1338
- hints.append(
1339
- "Artifacts changed since the gate was recorded. Re-run review and "
1340
- "record-gate for full-task-complete, then archive --check again."
1341
- )
1342
- continue
1343
- if "child" in error and "integrated or cancelled" in error:
1344
- hints.append(
1345
- "Advance each child in parent task-map to integrated or cancelled "
1346
- "via integrate-child before parent archive."
1347
- )
1348
- continue
1349
- if error == "handoff.md" or error.startswith("handoff.md"):
1350
- hints.append(
1351
- "Integrated children require handoff.md on the child task before archive."
1352
- )
1353
- continue
1354
-
1355
- if not hints and not guard.ok:
1356
- hints.append(
1357
- f"Run: python ./.trellis/scripts/task.py prepare-archive-evidence {rel_task} "
1358
- "then archive --check again."
1359
- )
1360
-
1361
- if guard.is_full_task and guard.required_gates:
1362
- missing_reviewer = any(
1363
- e.startswith("missing gate record: full-task-complete/")
1364
- for e in errors
1365
- )
1366
- if missing_reviewer and not any("record-gate" in h for h in hints):
1367
- for gate in guard.required_gates:
1368
- hints.append(
1369
- f"Full Task archive needs record-gate for full-task-complete/{gate} "
1370
- "(reviewer action required)."
1371
- )
1372
-
1373
- return _dedupe_preserve_order(hints)
1374
-
1375
-
1376
- def _dedupe_preserve_order(items: list[str]) -> list[str]:
1377
- seen: set[str] = set()
1378
- out: list[str] = []
1379
- for item in items:
1380
- if item in seen:
1381
- continue
1382
- seen.add(item)
1383
- out.append(item)
1384
- return out
1385
-
1386
-
1387
- def build_archive_evidence_draft(
1388
- task_dir: Path,
1389
- task_data: dict,
1390
- guard: GateGuardResult | None = None,
1391
- ) -> str:
1392
- """Build markdown sections that satisfy archive evidence regex checks."""
1393
- if guard is None:
1394
- guard = validate_archive(task_dir, task_data)
1395
-
1396
- status = _verify_evidence_status(task_dir, task_data)
1397
- child_names = task_data.get("children")
1398
- has_children = isinstance(child_names, list) and bool(child_names)
1399
- lines = [
1400
- "",
1401
- "## Archive evidence (draft)",
1402
- "",
1403
- ARCHIVE_EVIDENCE_DRAFT_MARKER,
1404
- "",
1405
- "_Auto-drafted by prepare-archive-evidence. Edit placeholders before archive._",
1406
- "",
1407
- ]
1408
-
1409
- if not status["validation"]:
1410
- lines.extend(
1411
- [
1412
- "Validation commands: (fill in commands and outcomes, e.g. pnpm test — pass)",
1413
- "",
1414
- ]
1415
- )
1416
- if not status["acceptance"]:
1417
- lines.extend(
1418
- [
1419
- "Final acceptance evidence: (describe acceptance criteria met for this task)",
1420
- "",
1421
- ]
1422
- )
1423
- if not status["durable_learning"]:
1424
- lines.extend(_learning_decision_draft_lines(task_dir, task_data))
1425
- if has_children and not status["integration"]:
1426
- summary = _integration_draft_summary(task_dir, child_names)
1427
- lines.extend(
1428
- [
1429
- f"Final integration evidence: {summary}",
1430
- "",
1431
- ]
1432
- )
1433
-
1434
- if guard.is_full_task and guard.required_gates:
1435
- rel_task = f".trellis/tasks/{task_dir.name}"
1436
- lines.extend(
1437
- [
1438
- "## Completion gate preparation (not recorded)",
1439
- "",
1440
- "Reviewer gates are **not** recorded by this helper. After review, run:",
1441
- "",
1442
- ]
1443
- )
1444
- for gate in guard.required_gates:
1445
- lines.append(
1446
- f"- `python ./.trellis/scripts/task.py record-gate {rel_task} "
1447
- f"--transition full-task-complete --gate {gate} --result PASS "
1448
- f"--reviewer <reviewer-id> --evidence verify.md`"
1449
- )
1450
- lines.append("")
1451
-
1452
- return "\n".join(lines).rstrip() + "\n"
1453
-
1454
-
1455
- def _integration_draft_summary(task_dir: Path, child_names: list) -> str:
1456
- """Summarize child integration states for a parent integration evidence line."""
1457
- names = [n for n in child_names if isinstance(n, str)]
1458
- if not names:
1459
- return "parent children integrated per task-map.md"
1460
-
1461
- data, _body = load_task_map(task_dir)
1462
- states: list[str] = []
1463
- if isinstance(data, dict):
1464
- children = data.get("children")
1465
- if isinstance(children, list):
1466
- by_id = {
1467
- entry.get("id"): entry.get("state")
1468
- for entry in children
1469
- if isinstance(entry, dict) and entry.get("id")
1470
- }
1471
- for name in names:
1472
- state = by_id.get(name, "unknown")
1473
- states.append(f"{name}={state}")
1474
-
1475
- if states:
1476
- return "children " + ", ".join(states) + " per task-map.md"
1477
- return "all structural children terminal in task-map.md before parent archive"
1478
-
1479
-
1480
- def prepare_archive_evidence(
1481
- task_dir: Path,
1482
- task_data: dict | None,
1483
- *,
1484
- dry_run: bool = False,
1485
- ) -> tuple[bool, list[str]]:
1486
- """
1487
- Append missing archive evidence sections to verify.md without rewriting user text.
1488
-
1489
- Returns (changed, messages).
1490
- """
1491
- messages: list[str] = []
1492
- if task_data is None:
1493
- return False, ["task.json missing or invalid"]
1494
-
1495
- verify_path = task_dir / "verify.md"
1496
- guard = validate_archive(task_dir, task_data)
1497
-
1498
- if verify_path.is_file():
1499
- try:
1500
- existing = verify_path.read_text(encoding="utf-8")
1501
- except OSError:
1502
- return False, ["verify.md could not be read"]
1503
- if ARCHIVE_EVIDENCE_DRAFT_MARKER in existing:
1504
- messages.append(
1505
- "verify.md already contains an archive evidence draft block; "
1506
- "edit it in place instead of re-running prepare."
1507
- )
1508
- still_missing = _verify_evidence_errors(task_dir, task_data)
1509
- if not still_missing:
1510
- return False, messages
1511
- messages.append(
1512
- "Some evidence is still missing after the draft block; "
1513
- "fill placeholders or add lines outside the draft section."
1514
- )
1515
- return False, messages
1516
- content = existing
1517
- else:
1518
- content = "# Verification Evidence\n"
1519
-
1520
- draft = build_archive_evidence_draft(task_dir, task_data, guard)
1521
- status = _verify_evidence_status(task_dir, task_data)
1522
- child_names = task_data.get("children")
1523
- has_children = isinstance(child_names, list) and bool(child_names)
1524
- needs_draft = (
1525
- not status["validation"]
1526
- or not status["acceptance"]
1527
- or not status["durable_learning"]
1528
- or (has_children and not status["integration"])
1529
- or (guard.is_full_task and guard.required_gates)
1530
- )
1531
- if not needs_draft:
1532
- messages.append("No missing archive evidence sections to draft.")
1533
- return False, messages
1534
-
1535
- new_content = content.rstrip() + "\n" + draft
1536
- if dry_run:
1537
- messages.append("Dry run: would append archive evidence draft to verify.md")
1538
- return True, messages
1539
-
1540
- try:
1541
- verify_path.write_text(new_content, encoding="utf-8")
1542
- except OSError as exc:
1543
- return False, [f"failed to write verify.md: {exc}"]
1544
-
1545
- messages.append("Appended archive evidence draft to verify.md")
1546
- remaining = _verify_evidence_errors(task_dir, task_data)
1547
- gate_errors = [
1548
- e
1549
- for e in validate_archive(task_dir, task_data).errors
1550
- if e.startswith("missing gate record:")
1551
- or e.startswith("gate failed:")
1552
- or e.startswith("stale ")
1553
- ]
1554
- if remaining:
1555
- messages.append(
1556
- "Still missing after draft (edit placeholders): " + "; ".join(remaining)
1557
- )
1558
- if gate_errors:
1559
- messages.append(
1560
- "Completion gates still require explicit record-gate: "
1561
- + "; ".join(gate_errors)
1562
- )
1563
- return True, messages
1564
-
1565
-
1566
- def validate_reviewer_gate_input(
1567
- transition: str,
1568
- gate: str,
1569
- result: str,
1570
- reviewer: str,
1571
- evidence: str,
1572
- issue_fingerprint: str | None = None,
1573
- issue_summary: str | None = None,
1574
- root_cause: str | None = None,
1575
- skip_approved_by: str | None = None,
1576
- skip_reason: str | None = None,
1577
- ) -> list[str]:
1578
- """Validate CLI inputs for record-gate."""
1579
- errors: list[str] = []
1580
-
1581
- if transition not in KNOWN_TRANSITIONS:
1582
- errors.append(f"unknown transition: {transition}")
1583
- if gate == BASELINE_GATE:
1584
- errors.append("baseline-check is CLI-owned and cannot be recorded manually")
1585
- elif gate not in REVIEW_GATES:
1586
- errors.append(f"unknown gate: {gate}")
1587
- if result not in RESULTS:
1588
- errors.append(f"invalid result: {result}")
1589
- if not _is_short_token(reviewer) or not REVIEWER_RE.match(reviewer):
1590
- errors.append("reviewer must be a short identifier")
1591
- if not _is_short_text(evidence):
1592
- errors.append("evidence must be a short reference, not a review body")
1593
-
1594
- if result == "PASS":
1595
- if issue_fingerprint:
1596
- errors.append("--issue-fingerprint is only valid for FAIL")
1597
- if root_cause:
1598
- errors.append("--root-cause is only valid for FAIL")
1599
- if skip_approved_by or skip_reason:
1600
- errors.append("skip metadata is only valid for SKIPPED")
1601
- elif result == "FAIL":
1602
- if not _is_short_token(issue_fingerprint):
1603
- errors.append("FAIL requires --issue-fingerprint")
1604
- if root_cause not in FAIL_ROOT_CAUSES:
1605
- errors.append(
1606
- "FAIL requires --root-cause implementation-defect|contract-changing-defect|validation-environment-blocker"
1607
- )
1608
- if issue_summary and not _is_short_text(issue_summary, MAX_REASON):
1609
- errors.append("--issue-summary must be short and single-line")
1610
- if skip_approved_by or skip_reason:
1611
- errors.append("skip metadata is only valid for SKIPPED")
1612
- elif result == "SKIPPED":
1613
- if issue_fingerprint or issue_summary or root_cause:
1614
- errors.append("issue metadata is only valid for FAIL")
1615
- if skip_approved_by != "user":
1616
- errors.append("SKIPPED requires --skip-approved-by user")
1617
- if not _is_short_text(skip_reason, MAX_REASON):
1618
- errors.append("SKIPPED requires --skip-reason")
1619
-
1620
- return errors
1621
-
1622
-
1623
- def _route_for_fail_root_cause(root_cause: str | None) -> str | None:
1624
- if root_cause is None:
1625
- return None
1626
- return FAIL_ROOT_CAUSES.get(root_cause)
1627
-
1628
-
1629
- def _validate_quality_gates(
1630
- gates: dict,
1631
- verification_profile: str | None,
1632
- ) -> list[str]:
1633
- errors: list[str] = []
1634
- mode = gates.get("mode")
1635
- if mode not in ALLOWED_GATE_MODES:
1636
- errors.append("quality_gates.mode must be profile or explicit")
1637
-
1638
- profile = gates.get("profile")
1639
- if profile and profile not in ALLOWED_VERIFICATION_PROFILES:
1640
- errors.append(f"invalid quality_gates.profile: {profile}")
1641
-
1642
- enabled = gates.get("enabled", [])
1643
- disabled = gates.get("disabled", [])
1644
- if enabled is None:
1645
- enabled = []
1646
- if disabled is None:
1647
- disabled = []
1648
-
1649
- if mode == "explicit":
1650
- if not isinstance(enabled, list):
1651
- errors.append("quality_gates.enabled must be a list in explicit mode")
1652
- if not isinstance(disabled, list):
1653
- errors.append("quality_gates.disabled must be a list in explicit mode")
1654
-
1655
- all_configured = []
1656
- if isinstance(enabled, list):
1657
- all_configured.extend(enabled)
1658
- if isinstance(disabled, list):
1659
- all_configured.extend(disabled)
1660
-
1661
- for gate in all_configured:
1662
- if gate not in KNOWN_GATES:
1663
- errors.append(f"unknown quality gate: {gate}")
1664
-
1665
- if BASELINE_GATE in disabled:
1666
- errors.append("baseline-check cannot be disabled")
1667
-
1668
- active = set(enabled) if mode == "explicit" else set(
1669
- PROFILE_DEFAULT_GATES.get(
1670
- profile or verification_profile or "standard",
1671
- PROFILE_DEFAULT_GATES["standard"],
1672
- )
1673
- )
1674
- if "architecture-deep-review" in active and "architecture-review" not in active:
1675
- errors.append("architecture-deep-review requires architecture-review")
1676
-
1677
- return errors
1678
-
1679
-
1680
- def _parse_inline_list(value: str) -> list[str] | str:
1681
- value = value.strip()
1682
- if value.startswith("[") and value.endswith("]"):
1683
- inner = value[1:-1].strip()
1684
- if not inner:
1685
- return []
1686
- return [
1687
- item.strip().strip("'\"")
1688
- for item in inner.split(",")
1689
- if item.strip()
1690
- ]
1691
- return value
1692
-
1693
-
1694
- def _hash_payload(payload: dict) -> str:
1695
- body = json.dumps(payload, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
1696
- digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
1697
- return f"sha256:{digest}"
1698
-
1699
-
1700
- def _stable_task_data(task_data: dict) -> dict:
1701
- stable: dict = {}
1702
- for key in STABLE_TASK_KEYS:
1703
- if key in task_data:
1704
- stable[key] = task_data.get(key)
1705
- return stable
1706
-
1707
-
1708
- def _parent_contract_metadata(
1709
- task_dir: Path,
1710
- task_data: dict,
1711
- transition: str,
1712
- ) -> dict | None:
1713
- if transition == "child-review":
1714
- parent_name = task_data.get("parent")
1715
- if not isinstance(parent_name, str) or not parent_name:
1716
- return None
1717
- parent_dir = task_dir.parent / parent_name
1718
- data, _ = load_task_map(parent_dir)
1719
- return _task_map_contract_metadata("child", parent_dir, data)
1720
-
1721
- if transition.startswith("parent-"):
1722
- data, _ = load_task_map(task_dir)
1723
- return _task_map_contract_metadata("parent", task_dir, data)
1724
-
1725
- return None
1726
-
1727
-
1728
- def _task_map_contract_metadata(
1729
- role: str,
1730
- parent_dir: Path,
1731
- task_map_data: dict | None,
1732
- ) -> dict:
1733
- return {
1734
- "role": role,
1735
- "parent": parent_dir.name,
1736
- "task_map_found": task_map_data is not None,
1737
- "contract_epoch": (
1738
- task_map_data.get("contract_epoch")
1739
- if isinstance(task_map_data, dict)
1740
- else None
1741
- ),
1742
- }
1743
-
1744
-
1745
- def _reviewed_change_set_metadata(
1746
- task_dir: Path,
1747
- transition: str,
1748
- ) -> dict | None:
1749
- evidence_files = _change_set_evidence_files_for(transition)
1750
- if not evidence_files:
1751
- return None
1752
-
1753
- files = []
1754
- for name in evidence_files:
1755
- path = task_dir / name
1756
- try:
1757
- content = path.read_text(encoding="utf-8") if path.is_file() else ""
1758
- except OSError:
1759
- content = ""
1760
- files.append(
1761
- {
1762
- "path": name,
1763
- "present": path.is_file(),
1764
- "entries": _extract_reviewed_change_set_entries(content),
1765
- }
1766
- )
1767
- return {"files": files}
1768
-
1769
-
1770
- def _change_set_evidence_files_for(transition: str) -> list[str]:
1771
- if transition == "full-task-complete":
1772
- return ["verify.md"]
1773
- if transition == "child-review":
1774
- return ["verify.md", "handoff.md"]
1775
- if transition.startswith("parent-"):
1776
- return ["task-map.md", "verify.md"]
1777
- return []
1778
-
1779
-
1780
- def _extract_reviewed_change_set_entries(content: str) -> list[str]:
1781
- return [
1782
- match.group(1).strip()
1783
- for match in REVIEWED_CHANGE_SET_RE.finditer(content)
1784
- ]
1785
-
1786
-
1787
- def _only_missing_gate_record(errors: list[str]) -> bool:
1788
- return bool(errors) and all(
1789
- error.startswith("missing gate record:") for error in errors
1790
- )
1791
-
1792
-
1793
- def make_planning_review_gate_record(
1794
- task_dir: Path,
1795
- task_data: dict,
1796
- transition: str,
1797
- gate: str,
1798
- contract: dict,
1799
- contract_fingerprint: str,
1800
- artifact_fingerprint: str,
1801
- ) -> dict:
1802
- """Build a CLI-owned PASS record when planning artifacts satisfy the gate."""
1803
- _ = task_dir, task_data, contract
1804
- evidence_by_gate = {
1805
- "requirements-review": "prd.md",
1806
- "architecture-review": "design.md+implement.md",
1807
- }
1808
- return {
1809
- "schema_version": SCHEMA_VERSION,
1810
- "transition": transition,
1811
- "gate": gate,
1812
- "result": "PASS",
1813
- "reviewer": AUTO_PLANNING_REVIEWER,
1814
- "evidence": evidence_by_gate.get(gate, "planning-artifacts"),
1815
- "checked_at": utc_now(),
1816
- "contract_fingerprint": contract_fingerprint,
1817
- "artifact_fingerprint": artifact_fingerprint,
1818
- "issue_fingerprint": None,
1819
- "root_cause": None,
1820
- "route": None,
1821
- "consecutive_failures": 0,
1822
- "required_user_choice": None,
1823
- "approved_skip": None,
1824
- "auto_recorded": True,
1825
- "auto_record_reason": "planning-artifacts",
1826
- }
1827
-
1828
-
1829
- def _start_execution_planning_gate_readiness_errors(
1830
- task_dir: Path,
1831
- gate: str,
1832
- *,
1833
- full_task: bool,
1834
- contract: dict,
1835
- ) -> list[str]:
1836
- if gate == "requirements-review":
1837
- return _prd_requirements_review_errors(task_dir)
1838
- if gate == "architecture-review":
1839
- errors = _required_file_errors(task_dir, ["design.md", "implement.md"])
1840
- if not full_task:
1841
- errors.append(
1842
- "architecture-review requires design.md and implement.md (Full Task)"
1843
- )
1844
- errors.extend(validate_strategy_contract(contract))
1845
- return errors
1846
- return [f"planning gate readiness check unsupported: {gate}"]
1847
-
1848
-
1849
- def _prd_requirements_review_errors(task_dir: Path) -> list[str]:
1850
- path = task_dir / "prd.md"
1851
- if not path.is_file():
1852
- return ["prd.md missing for requirements-review"]
1853
- try:
1854
- content = path.read_text(encoding="utf-8")
1855
- except OSError:
1856
- return ["prd.md could not be read for requirements-review"]
1857
- if not content.strip():
1858
- return ["prd.md is empty (requirements-review)"]
1859
- if not PRD_ACCEPTANCE_CRITERIA_HEADING_RE.search(content):
1860
- return [
1861
- "prd.md missing Acceptance Criteria heading (complete PRD Grill before start-execution)"
1862
- ]
1863
- criteria: list[str] = []
1864
- for match in PRD_ACCEPTANCE_ITEM_RE.finditer(content):
1865
- text = match.group(1).strip()
1866
- if text and not PRD_PLACEHOLDER_RE.match(text):
1867
- criteria.append(text)
1868
- if not criteria:
1869
- return [
1870
- "prd.md needs at least one non-placeholder Acceptance Criteria item (replace TBD checkboxes)"
1871
- ]
1872
- return []
1873
-
1874
-
1875
- def start_execution_repair_hints(
1876
- errors: list[str],
1877
- task_dir: Path,
1878
- ) -> list[str]:
1879
- """Map start-execution validation errors to actionable planning hints."""
1880
- hints: list[str] = []
1881
- rel_task = f".trellis/tasks/{task_dir.name}"
1882
- for error in errors:
1883
- if error.startswith("missing gate record:"):
1884
- _rest = error.split(":", 1)[1].strip()
1885
- _transition, gate = _rest.split("/", 1)
1886
- if gate in START_EXECUTION_AUTO_GATES:
1887
- hints.append(
1888
- "Complete planning artifacts so Trellis can auto-record this gate on "
1889
- f"`start-execution --approved` (requirements: prd.md Acceptance Criteria; "
1890
- "architecture: design.md + valid implement.md contract)."
1891
- )
1892
- else:
1893
- hints.append(
1894
- f"python ./.trellis/scripts/task.py record-gate {rel_task} "
1895
- f"--transition start-execution --gate {gate} --result PASS "
1896
- f"--reviewer <reviewer-id> --evidence verify.md"
1897
- )
1898
- continue
1899
- if "Acceptance Criteria" in error or "TBD" in error:
1900
- hints.append(
1901
- "Finish PRD Grill in trellis-brainstorm: fill Goal, Acceptance Criteria "
1902
- "(non-TBD checkboxes), then re-run start-execution --check."
1903
- )
1904
- continue
1905
- if "Development Strategy Contract" in error or error.startswith("invalid "):
1906
- hints.append(
1907
- "Fix the Development Strategy Contract block in implement.md "
1908
- "(execution_mode, isolation, verification_profile, quality_gates)."
1909
- )
1910
- continue
1911
- if error == "design.md and implement.md must be present together for Full Tasks":
1912
- hints.append(
1913
- "Add both design.md and implement.md for Full Tasks, or stay PRD-only for Lite."
1914
- )
1915
- return _dedupe_preserve_order(hints)
1916
-
1917
-
1918
- def _artifact_files_for(transition: str, gate: str) -> list[str]:
1919
- if transition == "start-execution":
1920
- return ["prd.md", "design.md", "implement.md"]
1921
- if transition == "full-task-complete":
1922
- return ["prd.md", "design.md", "implement.md", "verify.md"]
1923
- if transition == "child-review":
1924
- return ["prd.md", "design.md", "implement.md", "verify.md", "handoff.md"]
1925
- if transition in ("parent-accepted", "parent-integrated"):
1926
- return ["task-map.md", "verify.md"]
1927
- _ = gate
1928
- return ["prd.md", "verify.md"]
1929
-
1930
-
1931
- def _read_artifact_files(task_dir: Path, names: list[str]) -> list[dict]:
1932
- files = []
1933
- for name in names:
1934
- path = task_dir / name
1935
- try:
1936
- content = path.read_text(encoding="utf-8") if path.is_file() else None
1937
- except OSError:
1938
- content = None
1939
- files.append({"path": name, "content": content})
1940
- return files
1941
-
1942
-
1943
- def _required_file_errors(task_dir: Path, names: list[str]) -> list[str]:
1944
- errors = []
1945
- for name in names:
1946
- path = task_dir / name
1947
- if not path.is_file():
1948
- errors.append(name)
1949
- continue
1950
- try:
1951
- if not path.read_text(encoding="utf-8").strip():
1952
- errors.append(f"{name} is empty")
1953
- except OSError:
1954
- errors.append(f"{name} could not be read")
1955
- return errors
1956
-
1957
-
1958
- def _get_gate_record(task_data: dict, transition: str, gate: str) -> dict | None:
1959
- qgr = task_data.get("quality_gate_results")
1960
- if not isinstance(qgr, dict):
1961
- return None
1962
- transitions = qgr.get("transitions")
1963
- if not isinstance(transitions, dict):
1964
- return None
1965
- transition_records = transitions.get(transition)
1966
- if not isinstance(transition_records, dict):
1967
- return None
1968
- record = transition_records.get(gate)
1969
- return record if isinstance(record, dict) else None
1970
-
1971
-
1972
- def _validate_gate_record_for_transition(
1973
- task_data: dict,
1974
- transition: str,
1975
- gate: str,
1976
- contract_fingerprint: str,
1977
- artifact_fingerprint: str,
1978
- ) -> list[str]:
1979
- errors: list[str] = []
1980
- record = _get_gate_record(task_data, transition, gate)
1981
- if record is None:
1982
- errors.append(f"missing gate record: {transition}/{gate}")
1983
- return errors
1984
-
1985
- result = record.get("result")
1986
- if result == "FAIL":
1987
- errors.append(f"gate failed: {transition}/{gate}")
1988
- return errors
1989
- if result not in ("PASS", "SKIPPED"):
1990
- errors.append(f"gate record has invalid result: {transition}/{gate}")
1991
- return errors
1992
- if record.get("contract_fingerprint") != contract_fingerprint:
1993
- errors.append(f"stale contract fingerprint: {transition}/{gate}")
1994
- if record.get("artifact_fingerprint") != artifact_fingerprint:
1995
- errors.append(f"stale artifact fingerprint: {transition}/{gate}")
1996
- if result == "SKIPPED":
1997
- approved_skip = record.get("approved_skip")
1998
- if not isinstance(approved_skip, dict):
1999
- errors.append(f"SKIPPED gate lacks approval metadata: {transition}/{gate}")
2000
- elif approved_skip.get("approved_by") != "user" or not approved_skip.get("reason"):
2001
- errors.append(f"invalid SKIPPED approval metadata: {transition}/{gate}")
2002
- return errors
2003
-
2004
-
2005
- def _next_consecutive_failures(
2006
- previous: dict | None,
2007
- issue_fingerprint: str | None,
2008
- ) -> int:
2009
- if not previous or previous.get("result") != "FAIL":
2010
- return 1
2011
- if previous.get("issue_fingerprint") != issue_fingerprint:
2012
- return 1
2013
- previous_count = previous.get("consecutive_failures")
2014
- return int(previous_count) + 1 if isinstance(previous_count, int) else 2
2015
-
2016
-
2017
- def _is_short_token(value: str | None, max_len: int = MAX_SHORT_FIELD) -> bool:
2018
- if not value or not isinstance(value, str):
2019
- return False
2020
- if len(value) > max_len:
2021
- return False
2022
- if any(ch.isspace() for ch in value):
2023
- return False
2024
- return True
2025
-
2026
-
2027
- def _is_short_text(value: str | None, max_len: int = MAX_SHORT_FIELD) -> bool:
2028
- if not value or not isinstance(value, str):
2029
- return False
2030
- if len(value) > max_len:
2031
- return False
2032
- if "\n" in value or "\r" in value:
2033
- return False
2034
- return True
1
+ #!/usr/bin/env python3
2
+ """
3
+ Task quality gate helpers.
4
+
5
+ Gate records are stored in task.json under:
6
+ quality_gate_results.transitions[transition][gate]
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ from .task_map import (
19
+ get_child_state,
20
+ load_task_map,
21
+ validate_child_archive_state,
22
+ validate_parent_children_complete,
23
+ )
24
+
25
+ SCHEMA_VERSION = 1
26
+ BASELINE_GATE = "baseline-check"
27
+
28
+ KNOWN_TRANSITIONS = {
29
+ "start-execution",
30
+ "full-task-complete",
31
+ "child-review",
32
+ "parent-changes",
33
+ "parent-accepted",
34
+ "parent-integrating",
35
+ "parent-integrated",
36
+ "parent-cancelled",
37
+ }
38
+
39
+ REVIEW_GATES = {
40
+ "requirements-review",
41
+ "code-review",
42
+ "architecture-review",
43
+ "architecture-deep-review",
44
+ "integration-review",
45
+ }
46
+
47
+ KNOWN_GATES = {BASELINE_GATE, *REVIEW_GATES}
48
+ RESULTS = {"PASS", "FAIL", "SKIPPED"}
49
+ FAIL_ROOT_CAUSES = {
50
+ "implementation-defect": "Execution",
51
+ "contract-changing-defect": "Planning",
52
+ "validation-environment-blocker": "Verification / Review",
53
+ }
54
+
55
+ PROFILE_DEFAULT_GATES = {
56
+ "standard": ["requirements-review", "code-review"],
57
+ "strict": ["requirements-review", "code-review"],
58
+ "architecture": [
59
+ "requirements-review",
60
+ "architecture-review",
61
+ "code-review",
62
+ ],
63
+ }
64
+
65
+ ALLOWED_EXECUTION_MODES = {"inline", "worker", "child-task"}
66
+ ALLOWED_ISOLATION = {"main-worktree", "git-worktree"}
67
+ ALLOWED_VERIFICATION_PROFILES = {"standard", "strict", "architecture"}
68
+ ALLOWED_RETRIEVAL_PROFILES = {
69
+ "exact-only",
70
+ "semantic",
71
+ "structure",
72
+ "architecture-memory",
73
+ }
74
+ ALLOWED_GATE_MODES = {"profile", "explicit"}
75
+
76
+ MAX_SHORT_FIELD = 240
77
+ MAX_REASON = 500
78
+ REVIEWER_RE = re.compile(r"^[A-Za-z0-9_.:@/-]+$")
79
+ VALIDATION_EVIDENCE_RE = re.compile(
80
+ r"(?im)^\s*(?:[-*]\s*)?validation"
81
+ r"(?:\s+(?:commands?|results?|evidence))?\s*:\s*(\S[^\r\n]*)$"
82
+ )
83
+ ACCEPTANCE_EVIDENCE_RE = re.compile(
84
+ r"(?im)^\s*(?:[-*]\s*)?(?:final\s+|user\s+)?acceptance"
85
+ r"(?:\s+evidence)?\s*:\s*(\S[^\r\n]*)$"
86
+ )
87
+ ACCEPTED_BY_USER_RE = re.compile(
88
+ r"(?im)^\s*(?:[-*]\s*)?accepted\s+by\s+user\s*:\s*(\S[^\r\n]*)$"
89
+ )
90
+ NO_DURABLE_LEARNING_RE = re.compile(r"(?i)\bno\s+durable\s+learning\b")
91
+ DURABLE_LEARNING_EVIDENCE_RE = re.compile(
92
+ r"(?im)^\s*(?:[-*]\s*)?(?:durable\s+learning(?:\s+decision)?|learning\s+decision|"
93
+ r"spec\s+updates?|spec\s+update\s+(?:needed|evidence)|updated\s+spec|"
94
+ r"retrospective(?:\.md)?|learning\s+artifact)\s*:\s*(\S[^\r\n]*)$"
95
+ )
96
+ INTEGRATION_EVIDENCE_RE = re.compile(
97
+ r"(?im)^\s*(?:[-*]\s*)?(?:final\s+)?integration"
98
+ r"(?:\s+evidence)?\s*:\s*(\S[^\r\n]*)$"
99
+ )
100
+ REVIEWED_CHANGE_SET_RE = re.compile(
101
+ r"(?im)^\s*(?:[-*]\s*)?(?:reviewed\s+)?"
102
+ r"(?:change[- ]set|changeset|diff|git\s+diff|ref|git\s+ref)"
103
+ r"(?:\s+(?:identity|evidence|summary|ref))?\s*:\s*(\S[^\r\n]*)$"
104
+ )
105
+ CHECK_EVIDENCE_RE = re.compile(
106
+ r"(?im)^\s*(?:[-*]\s*)?(?:check\s+evidence|cstl-check(?:\s+evidence)?)"
107
+ r"\s*:\s*(\S[^\r\n]*)$"
108
+ )
109
+ PLACEHOLDER_VALUES_RE = re.compile(
110
+ r"(?i)^(TBD|TODO|待定|待补充|N/?A|NA|NONE|-|\.\.\.)$"
111
+ )
112
+ PRD_ACCEPTANCE_CRITERIA_HEADING_RE = re.compile(
113
+ r"(?im)^\s*#{1,6}\s*acceptance\s+criteria\s*$"
114
+ )
115
+ PRD_ACCEPTANCE_ITEM_RE = re.compile(
116
+ r"(?im)^\s*-\s*\[[ xX]\]\s+(.+)$"
117
+ )
118
+ PRD_PLACEHOLDER_RE = re.compile(r"(?i)^\s*(TBD|TODO|待定|待补充)(?:\s*[.::])?\s*$")
119
+ AUTO_PLANNING_REVIEWER = "cstl-cli"
120
+ START_EXECUTION_AUTO_GATES = frozenset({"requirements-review", "architecture-review"})
121
+
122
+ STABLE_TASK_KEYS = (
123
+ "id",
124
+ "name",
125
+ "title",
126
+ "description",
127
+ "status",
128
+ "dev_type",
129
+ "scope",
130
+ "package",
131
+ "priority",
132
+ "creator",
133
+ "assignee",
134
+ "parent",
135
+ "children",
136
+ "subtasks",
137
+ "relatedFiles",
138
+ "notes",
139
+ "meta",
140
+ "branch",
141
+ "base_branch",
142
+ "task_kind",
143
+ "task_type",
144
+ "kind",
145
+ "mode",
146
+ "contract_epoch",
147
+ )
148
+
149
+
150
+ @dataclass
151
+ class GateGuardResult:
152
+ """Result from a protected-transition guard."""
153
+
154
+ ok: bool
155
+ errors: list[str] = field(default_factory=list)
156
+ warnings: list[str] = field(default_factory=list)
157
+ contract_fingerprint: str = ""
158
+ artifact_fingerprints: dict[str, str] = field(default_factory=dict)
159
+ required_gates: list[str] = field(default_factory=list)
160
+ baseline_record: dict | None = None
161
+ is_full_task: bool = False
162
+ closeout_profile: str = "lite"
163
+ auto_gate_records: dict[str, dict] = field(default_factory=dict)
164
+
165
+
166
+ def utc_now() -> str:
167
+ """Return a compact UTC timestamp for task.json records."""
168
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace(
169
+ "+00:00", "Z"
170
+ )
171
+
172
+
173
+ def normalize_result(result: str) -> str:
174
+ """Normalize a CLI result value."""
175
+ return result.strip().upper()
176
+
177
+
178
+ def task_closeout_profile(task_dir: Path, task_data: dict | None = None) -> str:
179
+ """Return lite, full, or parent closeout profile."""
180
+ data = task_data or {}
181
+ child_names = data.get("children")
182
+ if isinstance(child_names, list) and any(isinstance(name, str) for name in child_names):
183
+ return "parent"
184
+
185
+ meta = data.get("meta") if isinstance(data.get("meta"), dict) else {}
186
+ candidates = [
187
+ data.get("task_kind"),
188
+ data.get("task_type"),
189
+ data.get("kind"),
190
+ data.get("mode"),
191
+ meta.get("task_kind"),
192
+ meta.get("task_type"),
193
+ meta.get("classification"),
194
+ meta.get("mode"),
195
+ ]
196
+
197
+ explicit_lite = False
198
+ for value in candidates:
199
+ if not isinstance(value, str):
200
+ continue
201
+ normalized = value.lower().replace("_", "-")
202
+ if "parent" in normalized:
203
+ return "parent"
204
+ if "full" in normalized:
205
+ return "full"
206
+ if "lite" in normalized:
207
+ explicit_lite = True
208
+
209
+ if explicit_lite:
210
+ return "lite"
211
+
212
+ if (task_dir / "design.md").is_file() and (task_dir / "implement.md").is_file():
213
+ return "full"
214
+ return "lite"
215
+
216
+
217
+ def is_full_task(task_dir: Path, task_data: dict | None = None) -> bool:
218
+ """Return True when a task should satisfy Full Task gates."""
219
+ return task_closeout_profile(task_dir, task_data) == "full"
220
+
221
+
222
+ def resolve_strategy_contract_for_gates(
223
+ task_dir: Path,
224
+ task_data: dict | None,
225
+ ) -> tuple[dict, list[str]]:
226
+ """Return the Development Strategy Contract used for gate fingerprints."""
227
+ if task_data is None:
228
+ return {}, []
229
+ profile = task_closeout_profile(task_dir, task_data)
230
+ if profile == "full":
231
+ return read_strategy_contract(task_dir)
232
+ if profile == "parent" and (task_dir / "implement.md").is_file():
233
+ return read_strategy_contract(task_dir)
234
+ return {}, []
235
+
236
+
237
+ def read_strategy_contract(task_dir: Path) -> tuple[dict, list[str]]:
238
+ """Parse the lightweight Development Strategy Contract from implement.md."""
239
+ implement_path = task_dir / "implement.md"
240
+ if not implement_path.is_file():
241
+ return {}, ["implement.md"]
242
+
243
+ try:
244
+ lines = implement_path.read_text(encoding="utf-8").splitlines()
245
+ except OSError:
246
+ return {}, ["implement.md could not be read"]
247
+
248
+ contract: dict = {}
249
+ section: str | None = None
250
+ list_key: str | None = None
251
+ started = False
252
+
253
+ top_level_keys = {
254
+ "execution_mode",
255
+ "isolation",
256
+ "verification_profile",
257
+ "retrieval_profile",
258
+ "optional_capabilities",
259
+ "quality_gates",
260
+ }
261
+
262
+ for raw_line in lines:
263
+ line = raw_line.rstrip()
264
+ stripped = line.strip()
265
+ if not stripped:
266
+ continue
267
+ if stripped.startswith("#") or stripped.startswith("```"):
268
+ continue
269
+
270
+ top_match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$", stripped)
271
+ if top_match:
272
+ key = top_match.group(1)
273
+ value = top_match.group(2).strip()
274
+ if key in top_level_keys:
275
+ started = True
276
+ section = key if key == "quality_gates" else None
277
+ list_key = key if key == "optional_capabilities" and not value else None
278
+ if key == "quality_gates":
279
+ contract.setdefault("quality_gates", {})
280
+ elif key == "optional_capabilities":
281
+ if value:
282
+ contract[key] = _parse_inline_list(value)
283
+ else:
284
+ contract[key] = []
285
+ else:
286
+ contract[key] = value
287
+ continue
288
+
289
+ if not started:
290
+ continue
291
+
292
+ nested_match = re.match(r"^\s+([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$", line)
293
+ if nested_match and section == "quality_gates":
294
+ key = nested_match.group(1)
295
+ value = nested_match.group(2).strip()
296
+ gates = contract.setdefault("quality_gates", {})
297
+ if value:
298
+ gates[key] = _parse_inline_list(value)
299
+ list_key = None
300
+ else:
301
+ gates[key] = []
302
+ list_key = f"quality_gates.{key}"
303
+ continue
304
+
305
+ item_match = re.match(r"^\s*-\s+(.+)$", line)
306
+ if item_match:
307
+ item = item_match.group(1).strip()
308
+ if list_key == "optional_capabilities":
309
+ contract.setdefault("optional_capabilities", []).append(item)
310
+ elif list_key and list_key.startswith("quality_gates."):
311
+ gate_key = list_key.split(".", 1)[1]
312
+ gates = contract.setdefault("quality_gates", {})
313
+ gates.setdefault(gate_key, []).append(item)
314
+
315
+ errors = validate_strategy_contract(contract)
316
+ return contract, errors
317
+
318
+
319
+ def validate_strategy_contract(contract: dict) -> list[str]:
320
+ """Validate the parsed Development Strategy Contract."""
321
+ errors: list[str] = []
322
+ required = [
323
+ "execution_mode",
324
+ "isolation",
325
+ "verification_profile",
326
+ "retrieval_profile",
327
+ "optional_capabilities",
328
+ "quality_gates",
329
+ ]
330
+ for key in required:
331
+ if key not in contract:
332
+ errors.append(f"Development Strategy Contract missing {key}")
333
+
334
+ execution_mode = contract.get("execution_mode")
335
+ if execution_mode and execution_mode not in ALLOWED_EXECUTION_MODES:
336
+ errors.append(f"invalid execution_mode: {execution_mode}")
337
+
338
+ isolation = contract.get("isolation")
339
+ if isolation and isolation not in ALLOWED_ISOLATION:
340
+ errors.append(f"invalid isolation: {isolation}")
341
+
342
+ verification_profile = contract.get("verification_profile")
343
+ if verification_profile and verification_profile not in ALLOWED_VERIFICATION_PROFILES:
344
+ errors.append(f"invalid verification_profile: {verification_profile}")
345
+
346
+ retrieval_profile = contract.get("retrieval_profile")
347
+ if retrieval_profile and retrieval_profile not in ALLOWED_RETRIEVAL_PROFILES:
348
+ errors.append(f"invalid retrieval_profile: {retrieval_profile}")
349
+
350
+ optional_capabilities = contract.get("optional_capabilities")
351
+ if "optional_capabilities" in contract and not isinstance(optional_capabilities, list):
352
+ errors.append("optional_capabilities must be a list")
353
+
354
+ gates = contract.get("quality_gates")
355
+ if isinstance(gates, dict):
356
+ errors.extend(_validate_quality_gates(gates, verification_profile))
357
+ elif "quality_gates" in contract:
358
+ errors.append("quality_gates must be a mapping")
359
+
360
+ return errors
361
+
362
+
363
+ def enabled_gates_for_contract(contract: dict) -> list[str]:
364
+ """Return enabled non-baseline gates from a parsed contract."""
365
+ gates = contract.get("quality_gates")
366
+ if not isinstance(gates, dict):
367
+ return []
368
+
369
+ mode = gates.get("mode", "profile")
370
+ if mode == "explicit":
371
+ enabled = gates.get("enabled", [])
372
+ return [g for g in enabled if isinstance(g, str)]
373
+
374
+ profile = gates.get("profile") or contract.get("verification_profile") or "standard"
375
+ return list(PROFILE_DEFAULT_GATES.get(profile, PROFILE_DEFAULT_GATES["standard"]))
376
+
377
+
378
+ def required_gates_for_transition(transition: str, contract: dict) -> list[str]:
379
+ """Return non-baseline gates required before a transition."""
380
+ enabled = set(enabled_gates_for_contract(contract))
381
+
382
+ if transition == "start-execution":
383
+ required = ["requirements-review"]
384
+ if "architecture-review" in enabled:
385
+ required.append("architecture-review")
386
+ return required
387
+
388
+ if transition in ("full-task-complete", "child-review"):
389
+ required = ["code-review"]
390
+ if "architecture-review" in enabled:
391
+ required.append("architecture-review")
392
+ if "architecture-deep-review" in enabled:
393
+ required.append("architecture-deep-review")
394
+ return required
395
+
396
+ if transition == "parent-integrated":
397
+ return ["integration-review"]
398
+
399
+ return []
400
+
401
+
402
+ def compute_contract_fingerprint(
403
+ task_dir: Path,
404
+ task_data: dict,
405
+ contract: dict | None = None,
406
+ ) -> str:
407
+ """Compute a task-level fingerprint excluding generated result fields."""
408
+ payload = {
409
+ "schema_version": SCHEMA_VERSION,
410
+ "task_dir": task_dir.name,
411
+ "stable_task": _stable_task_data(task_data),
412
+ "strategy_contract": contract or {},
413
+ }
414
+ return _hash_payload(payload)
415
+
416
+
417
+ def compute_artifact_fingerprint(
418
+ task_dir: Path,
419
+ task_data: dict,
420
+ transition: str,
421
+ gate: str,
422
+ ) -> str:
423
+ """Compute a transition/gate-scoped artifact fingerprint."""
424
+ files = _artifact_files_for(transition, gate)
425
+ payload = {
426
+ "schema_version": SCHEMA_VERSION,
427
+ "transition": transition,
428
+ "gate": gate,
429
+ "task_dir": task_dir.name,
430
+ "stable_task": _stable_task_data(task_data),
431
+ "parent_contract": _parent_contract_metadata(
432
+ task_dir,
433
+ task_data,
434
+ transition,
435
+ ),
436
+ "reviewed_change_set": _reviewed_change_set_metadata(
437
+ task_dir,
438
+ transition,
439
+ ),
440
+ "files": _read_artifact_files(task_dir, files),
441
+ }
442
+ return _hash_payload(payload)
443
+
444
+
445
+ def make_baseline_record(
446
+ task_dir: Path,
447
+ task_data: dict,
448
+ transition: str,
449
+ contract: dict | None = None,
450
+ evidence: str = "task.json",
451
+ ) -> dict:
452
+ """Build a CLI-owned baseline-check PASS record."""
453
+ contract_fingerprint = compute_contract_fingerprint(task_dir, task_data, contract)
454
+ artifact_fingerprint = compute_artifact_fingerprint(
455
+ task_dir, task_data, transition, BASELINE_GATE
456
+ )
457
+ return {
458
+ "schema_version": SCHEMA_VERSION,
459
+ "transition": transition,
460
+ "gate": BASELINE_GATE,
461
+ "result": "PASS",
462
+ "reviewer": "cstl-cli",
463
+ "evidence": evidence,
464
+ "checked_at": utc_now(),
465
+ "contract_fingerprint": contract_fingerprint,
466
+ "artifact_fingerprint": artifact_fingerprint,
467
+ "issue_fingerprint": None,
468
+ "consecutive_failures": 0,
469
+ "approved_skip": None,
470
+ }
471
+
472
+
473
+ def write_gate_record(task_data: dict, transition: str, gate: str, record: dict) -> None:
474
+ """Write a gate record into task_data in-place."""
475
+ qgr = task_data.get("quality_gate_results")
476
+ if not isinstance(qgr, dict):
477
+ qgr = {}
478
+ task_data["quality_gate_results"] = qgr
479
+
480
+ qgr["schema_version"] = SCHEMA_VERSION
481
+ qgr["contract_fingerprint"] = record.get("contract_fingerprint")
482
+ qgr["artifact_fingerprint"] = record.get("artifact_fingerprint")
483
+ transitions = qgr.get("transitions")
484
+ if not isinstance(transitions, dict):
485
+ transitions = {}
486
+ qgr["transitions"] = transitions
487
+
488
+ transition_records = transitions.get(transition)
489
+ if not isinstance(transition_records, dict):
490
+ transition_records = {}
491
+ transitions[transition] = transition_records
492
+ transition_records[gate] = record
493
+
494
+
495
+ def build_reviewer_gate_record(
496
+ task_dir: Path,
497
+ task_data: dict,
498
+ transition: str,
499
+ gate: str,
500
+ result: str,
501
+ reviewer: str,
502
+ evidence: str,
503
+ issue_fingerprint: str | None = None,
504
+ issue_summary: str | None = None,
505
+ root_cause: str | None = None,
506
+ skip_approved_by: str | None = None,
507
+ skip_reason: str | None = None,
508
+ contract_fingerprint: str | None = None,
509
+ artifact_fingerprint: str | None = None,
510
+ ) -> tuple[dict | None, list[str], list[str]]:
511
+ """Validate and build a non-baseline reviewer gate record."""
512
+ normalized_result = normalize_result(result)
513
+ errors = validate_reviewer_gate_input(
514
+ transition=transition,
515
+ gate=gate,
516
+ result=normalized_result,
517
+ reviewer=reviewer,
518
+ evidence=evidence,
519
+ issue_fingerprint=issue_fingerprint,
520
+ issue_summary=issue_summary,
521
+ root_cause=root_cause,
522
+ skip_approved_by=skip_approved_by,
523
+ skip_reason=skip_reason,
524
+ )
525
+ warnings: list[str] = []
526
+ if errors:
527
+ return None, errors, warnings
528
+
529
+ if normalized_result in ("PASS", "SKIPPED"):
530
+ errors.extend(
531
+ validate_transition_readiness(
532
+ task_dir,
533
+ task_data,
534
+ transition,
535
+ gate=gate,
536
+ mode="record",
537
+ )
538
+ )
539
+ if errors:
540
+ return None, errors, warnings
541
+
542
+ contract, contract_errors = resolve_strategy_contract_for_gates(task_dir, task_data)
543
+ if contract_errors:
544
+ errors.extend(contract_errors)
545
+ return None, errors, warnings
546
+
547
+ current_contract_fingerprint = compute_contract_fingerprint(
548
+ task_dir, task_data, contract
549
+ )
550
+ current_artifact_fingerprint = compute_artifact_fingerprint(
551
+ task_dir, task_data, transition, gate
552
+ )
553
+ if contract_fingerprint and contract_fingerprint != current_contract_fingerprint:
554
+ errors.append("provided contract fingerprint does not match current task artifacts")
555
+ if artifact_fingerprint and artifact_fingerprint != current_artifact_fingerprint:
556
+ errors.append("provided artifact fingerprint does not match current task artifacts")
557
+ if errors:
558
+ return None, errors, warnings
559
+
560
+ previous = _get_gate_record(task_data, transition, gate)
561
+ consecutive_failures = 0
562
+ required_user_choice = None
563
+ if normalized_result == "FAIL":
564
+ consecutive_failures = _next_consecutive_failures(previous, issue_fingerprint)
565
+ if consecutive_failures > 3:
566
+ required_user_choice = {
567
+ "required": True,
568
+ "reason": "same gate and issue fingerprint failed more than three times",
569
+ "options": [
570
+ "re-plan",
571
+ "continue-fixing",
572
+ "user-approved-skip-if-allowed",
573
+ ],
574
+ }
575
+ warnings.append(
576
+ "same gate and issue fingerprint has failed more than three times; ask the user to choose re-plan, continue fixing, or user-approved skip if allowed"
577
+ )
578
+
579
+ approved_skip = None
580
+ if normalized_result == "SKIPPED":
581
+ approved_skip = {
582
+ "approved_by": skip_approved_by,
583
+ "reason": skip_reason,
584
+ "approved_at": utc_now(),
585
+ }
586
+
587
+ record = {
588
+ "schema_version": SCHEMA_VERSION,
589
+ "transition": transition,
590
+ "gate": gate,
591
+ "result": normalized_result,
592
+ "reviewer": reviewer,
593
+ "evidence": evidence,
594
+ "checked_at": utc_now(),
595
+ "contract_fingerprint": current_contract_fingerprint,
596
+ "artifact_fingerprint": current_artifact_fingerprint,
597
+ "issue_fingerprint": issue_fingerprint if normalized_result == "FAIL" else None,
598
+ "root_cause": root_cause if normalized_result == "FAIL" else None,
599
+ "route": _route_for_fail_root_cause(root_cause) if normalized_result == "FAIL" else None,
600
+ "consecutive_failures": consecutive_failures,
601
+ "required_user_choice": required_user_choice,
602
+ "approved_skip": approved_skip,
603
+ }
604
+ if issue_summary and normalized_result == "FAIL":
605
+ record["issue_summary"] = issue_summary
606
+ return record, [], warnings
607
+
608
+
609
+ def validate_start_execution(
610
+ task_dir: Path,
611
+ task_data: dict | None,
612
+ approved: bool,
613
+ ) -> GateGuardResult:
614
+ """Validate start-execution readiness."""
615
+ errors: list[str] = []
616
+ if task_data is None:
617
+ return GateGuardResult(ok=False, errors=["task.json"])
618
+
619
+ status = task_data.get("status")
620
+ if status not in ("planning", "in_progress"):
621
+ errors.append(f"task status must be planning or in_progress, got {status!r}")
622
+
623
+ errors.extend(_required_file_errors(task_dir, ["prd.md"]))
624
+
625
+ has_design = (task_dir / "design.md").is_file()
626
+ has_implement = (task_dir / "implement.md").is_file()
627
+ if has_design != has_implement:
628
+ errors.append("design.md and implement.md must be present together for Full Tasks")
629
+
630
+ full_task = is_full_task(task_dir, task_data)
631
+ contract: dict = {}
632
+ required_gates: list[str] = []
633
+ if full_task:
634
+ contract, contract_errors = read_strategy_contract(task_dir)
635
+ errors.extend(contract_errors)
636
+ if not contract_errors:
637
+ required_gates = required_gates_for_transition("start-execution", contract)
638
+
639
+ contract_fingerprint = compute_contract_fingerprint(task_dir, task_data, contract)
640
+ artifact_fingerprints = {
641
+ BASELINE_GATE: compute_artifact_fingerprint(
642
+ task_dir, task_data, "start-execution", BASELINE_GATE
643
+ )
644
+ }
645
+
646
+ auto_gate_records: dict[str, dict] = {}
647
+ for gate in required_gates:
648
+ artifact_fingerprints[gate] = compute_artifact_fingerprint(
649
+ task_dir, task_data, "start-execution", gate
650
+ )
651
+ gate_errors = _validate_gate_record_for_transition(
652
+ task_data=task_data,
653
+ transition="start-execution",
654
+ gate=gate,
655
+ contract_fingerprint=contract_fingerprint,
656
+ artifact_fingerprint=artifact_fingerprints[gate],
657
+ )
658
+ if gate_errors and gate in START_EXECUTION_AUTO_GATES and _only_missing_gate_record(
659
+ gate_errors
660
+ ):
661
+ readiness_errors = _start_execution_planning_gate_readiness_errors(
662
+ task_dir,
663
+ gate,
664
+ full_task=full_task,
665
+ contract=contract,
666
+ )
667
+ if readiness_errors:
668
+ errors.extend(readiness_errors)
669
+ else:
670
+ auto_gate_records[gate] = make_planning_review_gate_record(
671
+ task_dir=task_dir,
672
+ task_data=task_data,
673
+ transition="start-execution",
674
+ gate=gate,
675
+ contract=contract,
676
+ contract_fingerprint=contract_fingerprint,
677
+ artifact_fingerprint=artifact_fingerprints[gate],
678
+ )
679
+ else:
680
+ errors.extend(gate_errors)
681
+
682
+ if approved:
683
+ errors.extend(
684
+ _validate_existing_execution_approval(
685
+ task_data=task_data,
686
+ contract_fingerprint=contract_fingerprint,
687
+ artifact_fingerprint=artifact_fingerprints[BASELINE_GATE],
688
+ )
689
+ )
690
+
691
+ if not approved:
692
+ errors.append("--approved is required for mutation")
693
+
694
+ baseline_record = None
695
+ if not errors:
696
+ baseline_record = make_baseline_record(
697
+ task_dir, task_data, "start-execution", contract
698
+ )
699
+
700
+ return GateGuardResult(
701
+ ok=not errors,
702
+ errors=errors,
703
+ contract_fingerprint=contract_fingerprint,
704
+ artifact_fingerprints=artifact_fingerprints,
705
+ required_gates=required_gates,
706
+ baseline_record=baseline_record,
707
+ is_full_task=full_task,
708
+ auto_gate_records=auto_gate_records,
709
+ )
710
+
711
+
712
+ def _validate_existing_execution_approval(
713
+ task_data: dict,
714
+ contract_fingerprint: str,
715
+ artifact_fingerprint: str,
716
+ ) -> list[str]:
717
+ """Reject stale approval state before re-approving an in-progress task."""
718
+ approval = task_data.get("execution_approval")
719
+ if not isinstance(approval, dict):
720
+ return []
721
+
722
+ errors: list[str] = []
723
+ if approval.get("transition") != "start-execution":
724
+ errors.append("stale execution approval: transition mismatch")
725
+ if approval.get("contract_fingerprint") != contract_fingerprint:
726
+ errors.append("stale execution approval: contract fingerprint changed")
727
+ if approval.get("artifact_fingerprint") != artifact_fingerprint:
728
+ errors.append("stale execution approval: artifact fingerprint changed")
729
+ if errors:
730
+ errors.append(
731
+ "return to Planning, refresh gates, run start-execution --check, and ask for explicit approval again"
732
+ )
733
+ return errors
734
+
735
+
736
+ def validate_start_execution_check(
737
+ task_dir: Path,
738
+ task_data: dict | None,
739
+ ) -> GateGuardResult:
740
+ """Validate start-execution preflight without requiring approval."""
741
+ result = validate_start_execution(task_dir, task_data, approved=True)
742
+ return result
743
+
744
+
745
+ def validate_transition_readiness(
746
+ task_dir: Path,
747
+ task_data: dict | None,
748
+ transition: str,
749
+ *,
750
+ gate: str | None = None,
751
+ mode: str = "record",
752
+ ) -> list[str]:
753
+ """Validate transition evidence and, in complete mode, required gate records."""
754
+ if task_data is None:
755
+ return ["task.json"]
756
+
757
+ errors: list[str] = []
758
+ if transition not in KNOWN_TRANSITIONS:
759
+ errors.append(f"unknown transition: {transition}")
760
+ return errors
761
+
762
+ profile = task_closeout_profile(task_dir, task_data)
763
+
764
+ if mode == "record":
765
+ if not gate:
766
+ errors.append("gate is required for record readiness checks")
767
+ return errors
768
+ required_signals = _evidence_requirements_for_gate(transition, gate)
769
+ errors.extend(_evidence_errors_for_signals(task_dir, task_data, required_signals))
770
+ return errors
771
+
772
+ if transition == "child-review":
773
+ if profile != "full":
774
+ return errors
775
+ contract, contract_errors = read_strategy_contract(task_dir)
776
+ errors.extend(contract_errors)
777
+ if contract_errors:
778
+ return errors
779
+ return errors + _complete_transition_gate_errors(
780
+ task_dir,
781
+ task_data,
782
+ transition,
783
+ contract,
784
+ required_gates_for_transition(transition, contract),
785
+ )
786
+
787
+ if transition == "full-task-complete":
788
+ if profile != "full":
789
+ return errors
790
+ contract, contract_errors = read_strategy_contract(task_dir)
791
+ errors.extend(contract_errors)
792
+ if contract_errors:
793
+ return errors
794
+ return errors + _complete_transition_gate_errors(
795
+ task_dir,
796
+ task_data,
797
+ transition,
798
+ contract,
799
+ required_gates_for_transition(transition, contract),
800
+ )
801
+
802
+ if transition == "parent-integrated":
803
+ if profile != "parent":
804
+ return errors
805
+ child_names = task_data.get("children")
806
+ if isinstance(child_names, list):
807
+ errors.extend(
808
+ validate_parent_children_complete(
809
+ task_dir,
810
+ [name for name in child_names if isinstance(name, str)],
811
+ )
812
+ )
813
+ errors.extend(
814
+ _evidence_errors_for_signals(task_dir, task_data, ["integration"])
815
+ )
816
+ contract, contract_errors = resolve_strategy_contract_for_gates(
817
+ task_dir,
818
+ task_data,
819
+ )
820
+ errors.extend(contract_errors)
821
+ if contract_errors:
822
+ return errors
823
+ return errors + _complete_transition_gate_errors(
824
+ task_dir,
825
+ task_data,
826
+ transition,
827
+ contract,
828
+ required_gates_for_transition(transition, contract),
829
+ )
830
+
831
+ return errors
832
+
833
+
834
+ def _complete_transition_gate_errors(
835
+ task_dir: Path,
836
+ task_data: dict,
837
+ transition: str,
838
+ contract: dict,
839
+ required_gates: list[str],
840
+ ) -> list[str]:
841
+ errors: list[str] = []
842
+ contract_fingerprint = compute_contract_fingerprint(task_dir, task_data, contract)
843
+ for gate_name in required_gates:
844
+ artifact_fingerprint = compute_artifact_fingerprint(
845
+ task_dir, task_data, transition, gate_name
846
+ )
847
+ errors.extend(
848
+ _validate_gate_record_for_transition(
849
+ task_data=task_data,
850
+ transition=transition,
851
+ gate=gate_name,
852
+ contract_fingerprint=contract_fingerprint,
853
+ artifact_fingerprint=artifact_fingerprint,
854
+ )
855
+ )
856
+ errors.extend(
857
+ _evidence_errors_for_signals(
858
+ task_dir,
859
+ task_data,
860
+ _evidence_requirements_for_gate(transition, gate_name),
861
+ )
862
+ )
863
+ return errors
864
+
865
+
866
+ def _evidence_requirements_for_gate(transition: str, gate: str) -> list[str]:
867
+ if gate == "code-review" and transition in ("full-task-complete", "child-review"):
868
+ return ["validation", "check_evidence", "reviewed_change_set"]
869
+ if gate == "integration-review" and transition == "parent-integrated":
870
+ return ["integration"]
871
+ if gate in ("architecture-review", "architecture-deep-review") and transition in (
872
+ "full-task-complete",
873
+ "child-review",
874
+ ):
875
+ return ["validation", "check_evidence"]
876
+ return []
877
+
878
+
879
+ def _evidence_errors_for_signals(
880
+ task_dir: Path,
881
+ task_data: dict,
882
+ required_signals: list[str],
883
+ ) -> list[str]:
884
+ status = verify_evidence_status(task_dir, task_data)
885
+ label_map = {
886
+ "validation": "verify.md missing substantive validation evidence",
887
+ "check_evidence": "verify.md missing check evidence",
888
+ "acceptance": "verify.md missing final acceptance evidence",
889
+ "durable_learning": "verify.md missing durable-learning decision evidence",
890
+ "integration": "verify.md missing final integration evidence",
891
+ "reviewed_change_set": (
892
+ "verify.md or handoff.md missing reviewed change-set evidence"
893
+ ),
894
+ }
895
+ errors: list[str] = []
896
+ for signal in required_signals:
897
+ if not status.get(signal):
898
+ errors.append(label_map.get(signal, f"missing evidence signal: {signal}"))
899
+ return errors
900
+
901
+
902
+ def verify_evidence_status(task_dir: Path, task_data: dict) -> dict[str, bool]:
903
+ """Return substantive evidence signals from verify.md and handoff.md."""
904
+ verify_path = task_dir / "verify.md"
905
+ content = ""
906
+ if verify_path.is_file():
907
+ try:
908
+ content = verify_path.read_text(encoding="utf-8")
909
+ except OSError:
910
+ content = ""
911
+
912
+ handoff_path = task_dir / "handoff.md"
913
+ handoff_content = ""
914
+ if handoff_path.is_file():
915
+ try:
916
+ handoff_content = handoff_path.read_text(encoding="utf-8")
917
+ except OSError:
918
+ handoff_content = ""
919
+
920
+ child_names = task_data.get("children")
921
+ has_children = isinstance(child_names, list) and bool(child_names)
922
+ return {
923
+ "validation": _has_substantive_validation_evidence(content),
924
+ "check_evidence": _has_substantive_check_evidence(content),
925
+ "acceptance": _has_substantive_acceptance_evidence(content),
926
+ "durable_learning": _has_durable_learning_evidence(content),
927
+ "integration": (
928
+ _has_substantive_integration_evidence(content) if has_children else True
929
+ ),
930
+ "reviewed_change_set": (
931
+ _has_substantive_reviewed_change_set(content)
932
+ or _has_substantive_reviewed_change_set(handoff_content)
933
+ ),
934
+ }
935
+
936
+
937
+ def validate_archive(
938
+ task_dir: Path,
939
+ task_data: dict | None,
940
+ ) -> GateGuardResult:
941
+ """Validate archive readiness without mutating task files."""
942
+ errors: list[str] = []
943
+ if task_data is None:
944
+ return GateGuardResult(ok=False, errors=["task.json"])
945
+
946
+ errors.extend(_required_file_errors(task_dir, ["verify.md"]))
947
+ errors.extend(_verify_evidence_errors(task_dir, task_data))
948
+
949
+ parent_name = task_data.get("parent")
950
+ if isinstance(parent_name, str) and parent_name:
951
+ parent_dir = task_dir.parent / parent_name
952
+ if not parent_dir.is_dir():
953
+ errors.append(f"parent task not found: {parent_name}")
954
+ else:
955
+ errors.extend(validate_child_archive_state(parent_dir, task_dir.name))
956
+ if get_child_state(parent_dir, task_dir.name) == "integrated":
957
+ errors.extend(_required_file_errors(task_dir, ["handoff.md"]))
958
+
959
+ child_names = task_data.get("children")
960
+ if isinstance(child_names, list) and child_names:
961
+ errors.extend(
962
+ validate_parent_children_complete(
963
+ task_dir,
964
+ [name for name in child_names if isinstance(name, str)],
965
+ )
966
+ )
967
+
968
+ profile = task_closeout_profile(task_dir, task_data)
969
+ contract: dict = {}
970
+ required_gates: list[str] = []
971
+ artifact_fingerprints: dict[str, str] = {}
972
+ baseline_record = None
973
+
974
+ if profile == "full":
975
+ contract, contract_errors = read_strategy_contract(task_dir)
976
+ errors.extend(contract_errors)
977
+ if not contract_errors:
978
+ required_gates = required_gates_for_transition(
979
+ "full-task-complete", contract
980
+ )
981
+ errors.extend(
982
+ validate_transition_readiness(
983
+ task_dir,
984
+ task_data,
985
+ "full-task-complete",
986
+ mode="complete",
987
+ )
988
+ )
989
+ elif profile == "parent":
990
+ contract, contract_errors = resolve_strategy_contract_for_gates(
991
+ task_dir,
992
+ task_data,
993
+ )
994
+ errors.extend(contract_errors)
995
+ if not contract_errors:
996
+ errors.extend(
997
+ validate_transition_readiness(
998
+ task_dir,
999
+ task_data,
1000
+ "parent-integrated",
1001
+ mode="complete",
1002
+ )
1003
+ )
1004
+ required_gates = required_gates_for_transition("parent-integrated", contract)
1005
+
1006
+ contract_fingerprint = compute_contract_fingerprint(task_dir, task_data, contract)
1007
+
1008
+ if profile == "full" and not errors:
1009
+ artifact_fingerprints[BASELINE_GATE] = compute_artifact_fingerprint(
1010
+ task_dir, task_data, "full-task-complete", BASELINE_GATE
1011
+ )
1012
+ for gate in required_gates:
1013
+ artifact_fingerprints[gate] = compute_artifact_fingerprint(
1014
+ task_dir, task_data, "full-task-complete", gate
1015
+ )
1016
+ baseline_record = make_baseline_record(
1017
+ task_dir, task_data, "full-task-complete", contract
1018
+ )
1019
+
1020
+ return GateGuardResult(
1021
+ ok=not errors,
1022
+ errors=errors,
1023
+ contract_fingerprint=contract_fingerprint,
1024
+ artifact_fingerprints=artifact_fingerprints,
1025
+ required_gates=required_gates,
1026
+ baseline_record=baseline_record,
1027
+ is_full_task=profile == "full",
1028
+ closeout_profile=profile,
1029
+ )
1030
+
1031
+
1032
+ def _verify_evidence_errors(task_dir: Path, task_data: dict) -> list[str]:
1033
+ """Validate human-readable archive evidence in verify.md."""
1034
+ verify_path = task_dir / "verify.md"
1035
+ if not verify_path.is_file():
1036
+ return []
1037
+
1038
+ try:
1039
+ verify_path.read_text(encoding="utf-8")
1040
+ except OSError:
1041
+ return ["verify.md could not be read for archive evidence"]
1042
+
1043
+ errors: list[str] = []
1044
+ status = verify_evidence_status(task_dir, task_data)
1045
+ if not status["validation"]:
1046
+ errors.append("verify.md missing validation evidence")
1047
+ if not status["acceptance"]:
1048
+ errors.append("verify.md missing final acceptance evidence")
1049
+ if not status["durable_learning"]:
1050
+ errors.append("verify.md missing durable-learning decision evidence")
1051
+ if not status["integration"]:
1052
+ errors.append("verify.md missing final integration evidence")
1053
+
1054
+ return errors
1055
+
1056
+
1057
+ def _is_substantive_evidence(value: str | None) -> bool:
1058
+ if not value or not isinstance(value, str):
1059
+ return False
1060
+ stripped = value.strip()
1061
+ if len(stripped) < 3:
1062
+ return False
1063
+ return not PLACEHOLDER_VALUES_RE.match(stripped)
1064
+
1065
+
1066
+ def _has_substantive_line_match(content: str, pattern: re.Pattern[str]) -> bool:
1067
+ if not content:
1068
+ return False
1069
+ for match in pattern.finditer(content):
1070
+ if match.lastindex and match.lastindex >= 1:
1071
+ if _is_substantive_evidence(match.group(1)):
1072
+ return True
1073
+ continue
1074
+ line = match.group(0)
1075
+ if ":" in line:
1076
+ value = line.split(":", 1)[1].strip()
1077
+ if _is_substantive_evidence(value):
1078
+ return True
1079
+ return False
1080
+
1081
+
1082
+ def _has_substantive_validation_evidence(content: str) -> bool:
1083
+ return _has_substantive_line_match(content, VALIDATION_EVIDENCE_RE)
1084
+
1085
+
1086
+ def _has_substantive_check_evidence(content: str) -> bool:
1087
+ return _has_substantive_line_match(content, CHECK_EVIDENCE_RE)
1088
+
1089
+
1090
+ def _has_substantive_acceptance_evidence(content: str) -> bool:
1091
+ return (
1092
+ _has_substantive_line_match(content, ACCEPTANCE_EVIDENCE_RE)
1093
+ or _has_substantive_line_match(content, ACCEPTED_BY_USER_RE)
1094
+ )
1095
+
1096
+
1097
+ def _has_substantive_integration_evidence(content: str) -> bool:
1098
+ return _has_substantive_line_match(content, INTEGRATION_EVIDENCE_RE)
1099
+
1100
+
1101
+ def _has_substantive_reviewed_change_set(content: str) -> bool:
1102
+ if not content:
1103
+ return False
1104
+ for match in REVIEWED_CHANGE_SET_RE.finditer(content):
1105
+ if _is_substantive_evidence(match.group(1)):
1106
+ return True
1107
+ return False
1108
+
1109
+
1110
+ def _has_validation_evidence(content: str) -> bool:
1111
+ return _has_substantive_validation_evidence(content)
1112
+
1113
+
1114
+ def _has_final_acceptance_evidence(content: str) -> bool:
1115
+ return _has_substantive_acceptance_evidence(content)
1116
+
1117
+
1118
+ def _has_durable_learning_evidence(content: str) -> bool:
1119
+ return bool(
1120
+ NO_DURABLE_LEARNING_RE.search(content)
1121
+ or _has_substantive_line_match(content, DURABLE_LEARNING_EVIDENCE_RE)
1122
+ )
1123
+
1124
+
1125
+ def durable_learning_decision_status(content: str) -> dict[str, bool]:
1126
+ """Return which durable-learning outcomes are signaled in verify.md text."""
1127
+ return {
1128
+ "no_durable_learning": bool(NO_DURABLE_LEARNING_RE.search(content)),
1129
+ "spec_update": bool(
1130
+ re.search(
1131
+ r"(?im)^\s*(?:[-*]\s*)?(?:spec\s+updates?|spec\s+update\s+"
1132
+ r"(?:needed|evidence)|updated\s+spec)\s*:\s*\S",
1133
+ content,
1134
+ )
1135
+ ),
1136
+ "learning_artifact": bool(
1137
+ re.search(
1138
+ r"(?im)^\s*(?:[-*]\s*)?(?:learning\s+artifact|retrospective(?:\.md)?)\s*:\s*\S",
1139
+ content,
1140
+ )
1141
+ or re.search(
1142
+ r"(?im)^\s*(?:[-*]\s*)?(?:durable\s+learning|learning\s+decision)\s*:\s*\S",
1143
+ content,
1144
+ )
1145
+ ),
1146
+ "any": _has_durable_learning_evidence(content),
1147
+ }
1148
+
1149
+
1150
+ def suggest_spec_targets(repo_root: Path, task_dir: Path, task_data: dict) -> list[str]:
1151
+ """Suggest existing spec paths from task scope; does not invent new rules."""
1152
+ suggestions: list[str] = []
1153
+ spec_root = repo_root / ".trellis" / "spec"
1154
+ if not spec_root.is_dir():
1155
+ return suggestions
1156
+
1157
+ package = task_data.get("package")
1158
+ if isinstance(package, str) and package.strip():
1159
+ pkg_index = spec_root / package.strip() / "index.md"
1160
+ if pkg_index.is_file():
1161
+ suggestions.append(f".trellis/spec/{package.strip()}/index.md")
1162
+
1163
+ scope = task_data.get("scope")
1164
+ if isinstance(scope, str) and scope.strip():
1165
+ scope_parts = [p for p in scope.strip().replace("\\", "/").split("/") if p]
1166
+ scope_path = spec_root.joinpath(*scope_parts) if scope_parts else spec_root
1167
+ if scope_path.is_dir():
1168
+ index = scope_path / "index.md"
1169
+ if index.is_file():
1170
+ suggestions.append(
1171
+ f".trellis/spec/{scope_path.relative_to(spec_root).as_posix()}/index.md"
1172
+ )
1173
+
1174
+ guide_paths = (
1175
+ "guides/durable-learning-decision-guide.md",
1176
+ "guides/index.md",
1177
+ )
1178
+ for rel in guide_paths:
1179
+ path = spec_root / rel
1180
+ if path.is_file():
1181
+ suggestions.append(f".trellis/spec/{rel}")
1182
+
1183
+ return _dedupe_preserve_order(suggestions)[:6]
1184
+
1185
+
1186
+ def build_spec_update_scaffold(
1187
+ repo_root: Path,
1188
+ task_dir: Path,
1189
+ task_data: dict,
1190
+ *,
1191
+ trigger: str | None = None,
1192
+ ) -> str:
1193
+ """Markdown checklist for spec capture; user/reviewer must confirm before editing specs."""
1194
+ targets = suggest_spec_targets(repo_root, task_dir, task_data)
1195
+ rel_task = f".trellis/tasks/{task_dir.name}"
1196
+ lines = [
1197
+ "## Spec update scaffold (reviewer-confirmed)",
1198
+ "",
1199
+ "_Suggestions only — do not treat this block as project policy until a human confirms._",
1200
+ "",
1201
+ ]
1202
+ if trigger:
1203
+ lines.extend([f"Trigger: {trigger.strip()}", ""])
1204
+ lines.extend(
1205
+ [
1206
+ "1. Decide outcome in verify.md:",
1207
+ " - Routine: `Durable learning decision: no durable learning`",
1208
+ " - Reusable insight: `Spec update evidence: .trellis/spec/<path>` after edits",
1209
+ f" - Already documented: `Learning artifact: {rel_task}/handoff.md`",
1210
+ "",
1211
+ "2. Use `/cstl:update-spec` or `/cstl:break-loop` for depth; never auto-write specs.",
1212
+ "",
1213
+ ]
1214
+ )
1215
+ if targets:
1216
+ lines.append("3. Existing spec indexes to consider (from task scope):")
1217
+ for target in targets:
1218
+ lines.append(f" - `{target}`")
1219
+ lines.append("")
1220
+ else:
1221
+ lines.append(
1222
+ "3. Browse `.trellis/spec/<package-or-layer>/index.md` for the right code-spec file."
1223
+ )
1224
+ lines.append("")
1225
+ lines.append(
1226
+ "4. Re-run `python ./.trellis/scripts/task.py archive <task> --check` after verify.md is final."
1227
+ )
1228
+ lines.append("")
1229
+ return "\n".join(lines)
1230
+
1231
+
1232
+ def _learning_decision_draft_lines(task_dir: Path, task_data: dict) -> list[str]:
1233
+ """Default durable-learning block for prepare-archive-evidence."""
1234
+ repo_root = task_dir.parent.parent.parent
1235
+ targets = suggest_spec_targets(repo_root, task_dir, task_data)
1236
+ target_hint = targets[0] if targets else ".trellis/spec/<layer>/index.md"
1237
+ return [
1238
+ "Durable learning decision: no durable learning for this task scope.",
1239
+ "",
1240
+ "# Replace the line above with ONE of these before archive:",
1241
+ f"# Spec update evidence: {target_hint}",
1242
+ f"# Learning artifact: .trellis/tasks/{task_dir.name}/handoff.md",
1243
+ "# Spec update needed: (brief reason) — then run /cstl:update-spec and point Spec update evidence at the edited file",
1244
+ "",
1245
+ ]
1246
+
1247
+
1248
+ ARCHIVE_EVIDENCE_DRAFT_MARKER = "<!-- trellis:archive-evidence-draft -->"
1249
+
1250
+
1251
+ def _verify_evidence_status(task_dir: Path, task_data: dict) -> dict[str, bool]:
1252
+ """Return which archive evidence sections are present in verify.md."""
1253
+ status = verify_evidence_status(task_dir, task_data)
1254
+ return {
1255
+ "validation": status["validation"],
1256
+ "acceptance": status["acceptance"],
1257
+ "durable_learning": status["durable_learning"],
1258
+ "integration": status["integration"],
1259
+ }
1260
+
1261
+
1262
+ def archive_repair_hints(
1263
+ errors: list[str],
1264
+ task_dir: Path,
1265
+ task_data: dict,
1266
+ guard: GateGuardResult,
1267
+ ) -> list[str]:
1268
+ """Map archive validation errors to actionable next-step hints."""
1269
+ hints: list[str] = []
1270
+ task_ref = task_dir.name
1271
+ rel_task = f".trellis/tasks/{task_ref}"
1272
+
1273
+ for error in errors:
1274
+ if error == "verify.md":
1275
+ hints.append(
1276
+ f"Create verify.md, then run: python ./.trellis/scripts/task.py "
1277
+ f"prepare-archive-evidence {rel_task}"
1278
+ )
1279
+ continue
1280
+ if error == "verify.md missing validation evidence":
1281
+ hints.append(
1282
+ "Add a grep-friendly line such as "
1283
+ "'Validation commands: <command> <outcome>' to verify.md, "
1284
+ f"or run: python ./.trellis/scripts/task.py prepare-archive-evidence {rel_task}"
1285
+ )
1286
+ continue
1287
+ if error == "verify.md missing final acceptance evidence":
1288
+ hints.append(
1289
+ "Add 'Final acceptance evidence: <criteria met>' or "
1290
+ "'Accepted by user: <who/when>' to verify.md, "
1291
+ f"or run prepare-archive-evidence {rel_task}"
1292
+ )
1293
+ continue
1294
+ if error == "verify.md missing durable-learning decision evidence":
1295
+ hints.append(
1296
+ "Durable learning decision (pick one grep-friendly line in verify.md): "
1297
+ "'Durable learning decision: no durable learning' for routine work; "
1298
+ "'Spec update evidence: .trellis/spec/<path>' after /cstl:update-spec; "
1299
+ "'Learning artifact: <path>' when handoff/retrospective already captures the insight. "
1300
+ f"Or run: python ./.trellis/scripts/task.py prepare-archive-evidence {rel_task}"
1301
+ )
1302
+ continue
1303
+ if error == "verify.md missing final integration evidence":
1304
+ hints.append(
1305
+ "Parent tasks need 'Final integration evidence: <child handoffs / task-map>'. "
1306
+ f"Run prepare-archive-evidence {rel_task} to draft a section from task-map.md"
1307
+ )
1308
+ continue
1309
+ if error.startswith("missing gate record:"):
1310
+ _rest = error.split(":", 1)[1].strip()
1311
+ _transition, gate = _rest.split("/", 1)
1312
+ transition = _transition.strip()
1313
+ if transition == "parent-integrated":
1314
+ hints.append(
1315
+ f"Parent archive needs record-gate for parent-integrated/{gate}: "
1316
+ f"python ./.trellis/scripts/task.py record-gate {rel_task} "
1317
+ f"--transition parent-integrated --gate {gate} --result PASS "
1318
+ f"--reviewer parent --evidence task-map.md"
1319
+ )
1320
+ elif transition == "child-review":
1321
+ hints.append(
1322
+ f"Full Child acceptance needs record-gate for child-review/{gate}: "
1323
+ f"python ./.trellis/scripts/task.py record-gate {rel_task} "
1324
+ f"--transition child-review --gate {gate} --result PASS "
1325
+ f"--reviewer parent --evidence verify.md"
1326
+ )
1327
+ else:
1328
+ hints.append(
1329
+ f"Record reviewer gate after explicit review (never auto-PASS): "
1330
+ f"python ./.trellis/scripts/task.py record-gate {rel_task} "
1331
+ f"--transition {transition} --gate {gate} --result PASS "
1332
+ f"--reviewer <reviewer-id> --evidence verify.md"
1333
+ )
1334
+ continue
1335
+ if error == "verify.md missing check evidence":
1336
+ hints.append(
1337
+ "Add a grep-friendly line such as "
1338
+ "'Check evidence: <cstl-check summary or manual review notes>' to verify.md"
1339
+ )
1340
+ continue
1341
+ if error == (
1342
+ "verify.md or handoff.md missing reviewed change-set evidence"
1343
+ ):
1344
+ hints.append(
1345
+ "Add 'Reviewed change-set: <git ref or diff summary>' to verify.md or handoff.md"
1346
+ )
1347
+ continue
1348
+ if error == "verify.md missing substantive validation evidence":
1349
+ hints.append(
1350
+ "Replace placeholder validation lines with substantive command/outcome text in verify.md"
1351
+ )
1352
+ continue
1353
+ if error.startswith("gate failed:"):
1354
+ hints.append(
1355
+ "Resolve the FAIL gate (fix, re-review, or user-approved SKIPPED) "
1356
+ "before archive."
1357
+ )
1358
+ continue
1359
+ if error.startswith("stale "):
1360
+ hints.append(
1361
+ "Artifacts changed since the gate was recorded. Re-run review and "
1362
+ "record-gate for full-task-complete, then archive --check again."
1363
+ )
1364
+ continue
1365
+ if "child" in error and "integrated or cancelled" in error:
1366
+ hints.append(
1367
+ "Advance each child in parent task-map to integrated or cancelled "
1368
+ "via integrate-child before parent archive."
1369
+ )
1370
+ continue
1371
+ if error == "handoff.md" or error.startswith("handoff.md"):
1372
+ hints.append(
1373
+ "Integrated children require handoff.md on the child task before archive."
1374
+ )
1375
+ continue
1376
+
1377
+ if not hints and not guard.ok:
1378
+ hints.append(
1379
+ f"Run: python ./.trellis/scripts/task.py prepare-archive-evidence {rel_task} "
1380
+ "then archive --check again."
1381
+ )
1382
+
1383
+ if guard.is_full_task and guard.required_gates:
1384
+ missing_reviewer = any(
1385
+ e.startswith("missing gate record: full-task-complete/")
1386
+ for e in errors
1387
+ )
1388
+ if missing_reviewer and not any("record-gate" in h for h in hints):
1389
+ for gate in guard.required_gates:
1390
+ hints.append(
1391
+ f"Full Task archive needs record-gate for full-task-complete/{gate} "
1392
+ "(reviewer action required)."
1393
+ )
1394
+
1395
+ return _dedupe_preserve_order(hints)
1396
+
1397
+
1398
+ def _dedupe_preserve_order(items: list[str]) -> list[str]:
1399
+ seen: set[str] = set()
1400
+ out: list[str] = []
1401
+ for item in items:
1402
+ if item in seen:
1403
+ continue
1404
+ seen.add(item)
1405
+ out.append(item)
1406
+ return out
1407
+
1408
+
1409
+ def build_archive_evidence_draft(
1410
+ task_dir: Path,
1411
+ task_data: dict,
1412
+ guard: GateGuardResult | None = None,
1413
+ ) -> str:
1414
+ """Build markdown sections that satisfy archive evidence regex checks."""
1415
+ if guard is None:
1416
+ guard = validate_archive(task_dir, task_data)
1417
+
1418
+ status = _verify_evidence_status(task_dir, task_data)
1419
+ child_names = task_data.get("children")
1420
+ has_children = isinstance(child_names, list) and bool(child_names)
1421
+ lines = [
1422
+ "",
1423
+ "## Archive evidence (draft)",
1424
+ "",
1425
+ ARCHIVE_EVIDENCE_DRAFT_MARKER,
1426
+ "",
1427
+ "_Auto-drafted by prepare-archive-evidence. Edit placeholders before archive._",
1428
+ "",
1429
+ ]
1430
+
1431
+ if not status["validation"]:
1432
+ lines.extend(
1433
+ [
1434
+ "Validation commands: (fill in commands and outcomes, e.g. pnpm test — pass)",
1435
+ "",
1436
+ ]
1437
+ )
1438
+ if not status["acceptance"]:
1439
+ lines.extend(
1440
+ [
1441
+ "Final acceptance evidence: (describe acceptance criteria met for this task)",
1442
+ "",
1443
+ ]
1444
+ )
1445
+ if not status["durable_learning"]:
1446
+ lines.extend(_learning_decision_draft_lines(task_dir, task_data))
1447
+ if has_children and not status["integration"]:
1448
+ summary = _integration_draft_summary(task_dir, child_names)
1449
+ lines.extend(
1450
+ [
1451
+ f"Final integration evidence: {summary}",
1452
+ "",
1453
+ ]
1454
+ )
1455
+
1456
+ if guard.is_full_task and guard.required_gates:
1457
+ rel_task = f".trellis/tasks/{task_dir.name}"
1458
+ lines.extend(
1459
+ [
1460
+ "## Completion gate preparation (not recorded)",
1461
+ "",
1462
+ "Reviewer gates are **not** recorded by this helper. After review, run:",
1463
+ "",
1464
+ ]
1465
+ )
1466
+ for gate in guard.required_gates:
1467
+ lines.append(
1468
+ f"- `python ./.trellis/scripts/task.py record-gate {rel_task} "
1469
+ f"--transition full-task-complete --gate {gate} --result PASS "
1470
+ f"--reviewer <reviewer-id> --evidence verify.md`"
1471
+ )
1472
+ lines.append("")
1473
+
1474
+ return "\n".join(lines).rstrip() + "\n"
1475
+
1476
+
1477
+ def _integration_draft_summary(task_dir: Path, child_names: list) -> str:
1478
+ """Summarize child integration states for a parent integration evidence line."""
1479
+ names = [n for n in child_names if isinstance(n, str)]
1480
+ if not names:
1481
+ return "parent children integrated per task-map.md"
1482
+
1483
+ data, _body = load_task_map(task_dir)
1484
+ states: list[str] = []
1485
+ if isinstance(data, dict):
1486
+ children = data.get("children")
1487
+ if isinstance(children, list):
1488
+ by_id = {
1489
+ entry.get("id"): entry.get("state")
1490
+ for entry in children
1491
+ if isinstance(entry, dict) and entry.get("id")
1492
+ }
1493
+ for name in names:
1494
+ state = by_id.get(name, "unknown")
1495
+ states.append(f"{name}={state}")
1496
+
1497
+ if states:
1498
+ return "children " + ", ".join(states) + " per task-map.md"
1499
+ return "all structural children terminal in task-map.md before parent archive"
1500
+
1501
+
1502
+ def prepare_archive_evidence(
1503
+ task_dir: Path,
1504
+ task_data: dict | None,
1505
+ *,
1506
+ dry_run: bool = False,
1507
+ ) -> tuple[bool, list[str]]:
1508
+ """
1509
+ Append missing archive evidence sections to verify.md without rewriting user text.
1510
+
1511
+ Returns (changed, messages).
1512
+ """
1513
+ messages: list[str] = []
1514
+ if task_data is None:
1515
+ return False, ["task.json missing or invalid"]
1516
+
1517
+ verify_path = task_dir / "verify.md"
1518
+ guard = validate_archive(task_dir, task_data)
1519
+
1520
+ if verify_path.is_file():
1521
+ try:
1522
+ existing = verify_path.read_text(encoding="utf-8")
1523
+ except OSError:
1524
+ return False, ["verify.md could not be read"]
1525
+ if ARCHIVE_EVIDENCE_DRAFT_MARKER in existing:
1526
+ messages.append(
1527
+ "verify.md already contains an archive evidence draft block; "
1528
+ "edit it in place instead of re-running prepare."
1529
+ )
1530
+ still_missing = _verify_evidence_errors(task_dir, task_data)
1531
+ if not still_missing:
1532
+ return False, messages
1533
+ messages.append(
1534
+ "Some evidence is still missing after the draft block; "
1535
+ "fill placeholders or add lines outside the draft section."
1536
+ )
1537
+ return False, messages
1538
+ content = existing
1539
+ else:
1540
+ content = "# Verification Evidence\n"
1541
+
1542
+ draft = build_archive_evidence_draft(task_dir, task_data, guard)
1543
+ status = _verify_evidence_status(task_dir, task_data)
1544
+ child_names = task_data.get("children")
1545
+ has_children = isinstance(child_names, list) and bool(child_names)
1546
+ needs_draft = (
1547
+ not status["validation"]
1548
+ or not status["acceptance"]
1549
+ or not status["durable_learning"]
1550
+ or (has_children and not status["integration"])
1551
+ or (guard.is_full_task and guard.required_gates)
1552
+ )
1553
+ if not needs_draft:
1554
+ messages.append("No missing archive evidence sections to draft.")
1555
+ return False, messages
1556
+
1557
+ new_content = content.rstrip() + "\n" + draft
1558
+ if dry_run:
1559
+ messages.append("Dry run: would append archive evidence draft to verify.md")
1560
+ return True, messages
1561
+
1562
+ try:
1563
+ verify_path.write_text(new_content, encoding="utf-8")
1564
+ except OSError as exc:
1565
+ return False, [f"failed to write verify.md: {exc}"]
1566
+
1567
+ messages.append("Appended archive evidence draft to verify.md")
1568
+ remaining = _verify_evidence_errors(task_dir, task_data)
1569
+ gate_errors = [
1570
+ e
1571
+ for e in validate_archive(task_dir, task_data).errors
1572
+ if e.startswith("missing gate record:")
1573
+ or e.startswith("gate failed:")
1574
+ or e.startswith("stale ")
1575
+ ]
1576
+ if remaining:
1577
+ messages.append(
1578
+ "Still missing after draft (edit placeholders): " + "; ".join(remaining)
1579
+ )
1580
+ if gate_errors:
1581
+ messages.append(
1582
+ "Completion gates still require explicit record-gate: "
1583
+ + "; ".join(gate_errors)
1584
+ )
1585
+ return True, messages
1586
+
1587
+
1588
+ def validate_reviewer_gate_input(
1589
+ transition: str,
1590
+ gate: str,
1591
+ result: str,
1592
+ reviewer: str,
1593
+ evidence: str,
1594
+ issue_fingerprint: str | None = None,
1595
+ issue_summary: str | None = None,
1596
+ root_cause: str | None = None,
1597
+ skip_approved_by: str | None = None,
1598
+ skip_reason: str | None = None,
1599
+ ) -> list[str]:
1600
+ """Validate CLI inputs for record-gate."""
1601
+ errors: list[str] = []
1602
+
1603
+ if transition not in KNOWN_TRANSITIONS:
1604
+ errors.append(f"unknown transition: {transition}")
1605
+ if gate == BASELINE_GATE:
1606
+ errors.append("baseline-check is CLI-owned and cannot be recorded manually")
1607
+ elif gate not in REVIEW_GATES:
1608
+ errors.append(f"unknown gate: {gate}")
1609
+ if result not in RESULTS:
1610
+ errors.append(f"invalid result: {result}")
1611
+ if not _is_short_token(reviewer) or not REVIEWER_RE.match(reviewer):
1612
+ errors.append("reviewer must be a short identifier")
1613
+ if not _is_short_text(evidence):
1614
+ errors.append("evidence must be a short reference, not a review body")
1615
+
1616
+ if result == "PASS":
1617
+ if issue_fingerprint:
1618
+ errors.append("--issue-fingerprint is only valid for FAIL")
1619
+ if root_cause:
1620
+ errors.append("--root-cause is only valid for FAIL")
1621
+ if skip_approved_by or skip_reason:
1622
+ errors.append("skip metadata is only valid for SKIPPED")
1623
+ elif result == "FAIL":
1624
+ if not _is_short_token(issue_fingerprint):
1625
+ errors.append("FAIL requires --issue-fingerprint")
1626
+ if root_cause not in FAIL_ROOT_CAUSES:
1627
+ errors.append(
1628
+ "FAIL requires --root-cause implementation-defect|contract-changing-defect|validation-environment-blocker"
1629
+ )
1630
+ if issue_summary and not _is_short_text(issue_summary, MAX_REASON):
1631
+ errors.append("--issue-summary must be short and single-line")
1632
+ if skip_approved_by or skip_reason:
1633
+ errors.append("skip metadata is only valid for SKIPPED")
1634
+ elif result == "SKIPPED":
1635
+ if issue_fingerprint or issue_summary or root_cause:
1636
+ errors.append("issue metadata is only valid for FAIL")
1637
+ if skip_approved_by != "user":
1638
+ errors.append("SKIPPED requires --skip-approved-by user")
1639
+ if not _is_short_text(skip_reason, MAX_REASON):
1640
+ errors.append("SKIPPED requires --skip-reason")
1641
+
1642
+ return errors
1643
+
1644
+
1645
+ def _route_for_fail_root_cause(root_cause: str | None) -> str | None:
1646
+ if root_cause is None:
1647
+ return None
1648
+ return FAIL_ROOT_CAUSES.get(root_cause)
1649
+
1650
+
1651
+ def _validate_quality_gates(
1652
+ gates: dict,
1653
+ verification_profile: str | None,
1654
+ ) -> list[str]:
1655
+ errors: list[str] = []
1656
+ mode = gates.get("mode")
1657
+ if mode not in ALLOWED_GATE_MODES:
1658
+ errors.append("quality_gates.mode must be profile or explicit")
1659
+
1660
+ profile = gates.get("profile")
1661
+ if profile and profile not in ALLOWED_VERIFICATION_PROFILES:
1662
+ errors.append(f"invalid quality_gates.profile: {profile}")
1663
+
1664
+ enabled = gates.get("enabled", [])
1665
+ disabled = gates.get("disabled", [])
1666
+ if enabled is None:
1667
+ enabled = []
1668
+ if disabled is None:
1669
+ disabled = []
1670
+
1671
+ if mode == "explicit":
1672
+ if not isinstance(enabled, list):
1673
+ errors.append("quality_gates.enabled must be a list in explicit mode")
1674
+ if not isinstance(disabled, list):
1675
+ errors.append("quality_gates.disabled must be a list in explicit mode")
1676
+
1677
+ all_configured = []
1678
+ if isinstance(enabled, list):
1679
+ all_configured.extend(enabled)
1680
+ if isinstance(disabled, list):
1681
+ all_configured.extend(disabled)
1682
+
1683
+ for gate in all_configured:
1684
+ if gate not in KNOWN_GATES:
1685
+ errors.append(f"unknown quality gate: {gate}")
1686
+
1687
+ if BASELINE_GATE in disabled:
1688
+ errors.append("baseline-check cannot be disabled")
1689
+
1690
+ active = set(enabled) if mode == "explicit" else set(
1691
+ PROFILE_DEFAULT_GATES.get(
1692
+ profile or verification_profile or "standard",
1693
+ PROFILE_DEFAULT_GATES["standard"],
1694
+ )
1695
+ )
1696
+ if "architecture-deep-review" in active and "architecture-review" not in active:
1697
+ errors.append("architecture-deep-review requires architecture-review")
1698
+
1699
+ return errors
1700
+
1701
+
1702
+ def _parse_inline_list(value: str) -> list[str] | str:
1703
+ value = value.strip()
1704
+ if value.startswith("[") and value.endswith("]"):
1705
+ inner = value[1:-1].strip()
1706
+ if not inner:
1707
+ return []
1708
+ return [
1709
+ item.strip().strip("'\"")
1710
+ for item in inner.split(",")
1711
+ if item.strip()
1712
+ ]
1713
+ return value
1714
+
1715
+
1716
+ def _hash_payload(payload: dict) -> str:
1717
+ body = json.dumps(payload, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
1718
+ digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
1719
+ return f"sha256:{digest}"
1720
+
1721
+
1722
+ def _stable_task_data(task_data: dict) -> dict:
1723
+ stable: dict = {}
1724
+ for key in STABLE_TASK_KEYS:
1725
+ if key in task_data:
1726
+ stable[key] = task_data.get(key)
1727
+ return stable
1728
+
1729
+
1730
+ def _parent_contract_metadata(
1731
+ task_dir: Path,
1732
+ task_data: dict,
1733
+ transition: str,
1734
+ ) -> dict | None:
1735
+ if transition == "child-review":
1736
+ parent_name = task_data.get("parent")
1737
+ if not isinstance(parent_name, str) or not parent_name:
1738
+ return None
1739
+ parent_dir = task_dir.parent / parent_name
1740
+ data, _ = load_task_map(parent_dir)
1741
+ return _task_map_contract_metadata("child", parent_dir, data)
1742
+
1743
+ if transition.startswith("parent-"):
1744
+ data, _ = load_task_map(task_dir)
1745
+ return _task_map_contract_metadata("parent", task_dir, data)
1746
+
1747
+ return None
1748
+
1749
+
1750
+ def _task_map_contract_metadata(
1751
+ role: str,
1752
+ parent_dir: Path,
1753
+ task_map_data: dict | None,
1754
+ ) -> dict:
1755
+ return {
1756
+ "role": role,
1757
+ "parent": parent_dir.name,
1758
+ "task_map_found": task_map_data is not None,
1759
+ "contract_epoch": (
1760
+ task_map_data.get("contract_epoch")
1761
+ if isinstance(task_map_data, dict)
1762
+ else None
1763
+ ),
1764
+ }
1765
+
1766
+
1767
+ def _reviewed_change_set_metadata(
1768
+ task_dir: Path,
1769
+ transition: str,
1770
+ ) -> dict | None:
1771
+ evidence_files = _change_set_evidence_files_for(transition)
1772
+ if not evidence_files:
1773
+ return None
1774
+
1775
+ files = []
1776
+ for name in evidence_files:
1777
+ path = task_dir / name
1778
+ try:
1779
+ content = path.read_text(encoding="utf-8") if path.is_file() else ""
1780
+ except OSError:
1781
+ content = ""
1782
+ files.append(
1783
+ {
1784
+ "path": name,
1785
+ "present": path.is_file(),
1786
+ "entries": _extract_reviewed_change_set_entries(content),
1787
+ }
1788
+ )
1789
+ return {"files": files}
1790
+
1791
+
1792
+ def _change_set_evidence_files_for(transition: str) -> list[str]:
1793
+ if transition == "full-task-complete":
1794
+ return ["verify.md"]
1795
+ if transition == "child-review":
1796
+ return ["verify.md", "handoff.md"]
1797
+ if transition.startswith("parent-"):
1798
+ return ["task-map.md", "verify.md"]
1799
+ return []
1800
+
1801
+
1802
+ def _extract_reviewed_change_set_entries(content: str) -> list[str]:
1803
+ return [
1804
+ match.group(1).strip()
1805
+ for match in REVIEWED_CHANGE_SET_RE.finditer(content)
1806
+ ]
1807
+
1808
+
1809
+ def _only_missing_gate_record(errors: list[str]) -> bool:
1810
+ return bool(errors) and all(
1811
+ error.startswith("missing gate record:") for error in errors
1812
+ )
1813
+
1814
+
1815
+ def make_planning_review_gate_record(
1816
+ task_dir: Path,
1817
+ task_data: dict,
1818
+ transition: str,
1819
+ gate: str,
1820
+ contract: dict,
1821
+ contract_fingerprint: str,
1822
+ artifact_fingerprint: str,
1823
+ ) -> dict:
1824
+ """Build a CLI-owned PASS record when planning artifacts satisfy the gate."""
1825
+ _ = task_dir, task_data, contract
1826
+ evidence_by_gate = {
1827
+ "requirements-review": "prd.md",
1828
+ "architecture-review": "design.md+implement.md",
1829
+ }
1830
+ return {
1831
+ "schema_version": SCHEMA_VERSION,
1832
+ "transition": transition,
1833
+ "gate": gate,
1834
+ "result": "PASS",
1835
+ "reviewer": AUTO_PLANNING_REVIEWER,
1836
+ "evidence": evidence_by_gate.get(gate, "planning-artifacts"),
1837
+ "checked_at": utc_now(),
1838
+ "contract_fingerprint": contract_fingerprint,
1839
+ "artifact_fingerprint": artifact_fingerprint,
1840
+ "issue_fingerprint": None,
1841
+ "root_cause": None,
1842
+ "route": None,
1843
+ "consecutive_failures": 0,
1844
+ "required_user_choice": None,
1845
+ "approved_skip": None,
1846
+ "auto_recorded": True,
1847
+ "auto_record_reason": "planning-artifacts",
1848
+ }
1849
+
1850
+
1851
+ def _start_execution_planning_gate_readiness_errors(
1852
+ task_dir: Path,
1853
+ gate: str,
1854
+ *,
1855
+ full_task: bool,
1856
+ contract: dict,
1857
+ ) -> list[str]:
1858
+ if gate == "requirements-review":
1859
+ return _prd_requirements_review_errors(task_dir)
1860
+ if gate == "architecture-review":
1861
+ errors = _required_file_errors(task_dir, ["design.md", "implement.md"])
1862
+ if not full_task:
1863
+ errors.append(
1864
+ "architecture-review requires design.md and implement.md (Full Task)"
1865
+ )
1866
+ errors.extend(validate_strategy_contract(contract))
1867
+ return errors
1868
+ return [f"planning gate readiness check unsupported: {gate}"]
1869
+
1870
+
1871
+ def _prd_requirements_review_errors(task_dir: Path) -> list[str]:
1872
+ path = task_dir / "prd.md"
1873
+ if not path.is_file():
1874
+ return ["prd.md missing for requirements-review"]
1875
+ try:
1876
+ content = path.read_text(encoding="utf-8")
1877
+ except OSError:
1878
+ return ["prd.md could not be read for requirements-review"]
1879
+ if not content.strip():
1880
+ return ["prd.md is empty (requirements-review)"]
1881
+ if not PRD_ACCEPTANCE_CRITERIA_HEADING_RE.search(content):
1882
+ return [
1883
+ "prd.md missing Acceptance Criteria heading (complete PRD Grill before start-execution)"
1884
+ ]
1885
+ criteria: list[str] = []
1886
+ for match in PRD_ACCEPTANCE_ITEM_RE.finditer(content):
1887
+ text = match.group(1).strip()
1888
+ if text and not PRD_PLACEHOLDER_RE.match(text):
1889
+ criteria.append(text)
1890
+ if not criteria:
1891
+ return [
1892
+ "prd.md needs at least one non-placeholder Acceptance Criteria item (replace TBD checkboxes)"
1893
+ ]
1894
+ return []
1895
+
1896
+
1897
+ def start_execution_repair_hints(
1898
+ errors: list[str],
1899
+ task_dir: Path,
1900
+ ) -> list[str]:
1901
+ """Map start-execution validation errors to actionable planning hints."""
1902
+ hints: list[str] = []
1903
+ rel_task = f".trellis/tasks/{task_dir.name}"
1904
+ for error in errors:
1905
+ if error.startswith("missing gate record:"):
1906
+ _rest = error.split(":", 1)[1].strip()
1907
+ _transition, gate = _rest.split("/", 1)
1908
+ if gate in START_EXECUTION_AUTO_GATES:
1909
+ hints.append(
1910
+ "Complete planning artifacts so Trellis can auto-record this gate on "
1911
+ f"`start-execution --approved` (requirements: prd.md Acceptance Criteria; "
1912
+ "architecture: design.md + valid implement.md contract)."
1913
+ )
1914
+ else:
1915
+ hints.append(
1916
+ f"python ./.trellis/scripts/task.py record-gate {rel_task} "
1917
+ f"--transition start-execution --gate {gate} --result PASS "
1918
+ f"--reviewer <reviewer-id> --evidence verify.md"
1919
+ )
1920
+ continue
1921
+ if "Acceptance Criteria" in error or "TBD" in error:
1922
+ hints.append(
1923
+ "Finish PRD Grill in cstl-brainstorm: fill Goal, Acceptance Criteria "
1924
+ "(non-TBD checkboxes), then re-run start-execution --check."
1925
+ )
1926
+ continue
1927
+ if "Development Strategy Contract" in error or error.startswith("invalid "):
1928
+ hints.append(
1929
+ "Fix the Development Strategy Contract block in implement.md "
1930
+ "(execution_mode, isolation, verification_profile, quality_gates)."
1931
+ )
1932
+ continue
1933
+ if error == "design.md and implement.md must be present together for Full Tasks":
1934
+ hints.append(
1935
+ "Add both design.md and implement.md for Full Tasks, or stay PRD-only for Lite."
1936
+ )
1937
+ return _dedupe_preserve_order(hints)
1938
+
1939
+
1940
+ def _artifact_files_for(transition: str, gate: str) -> list[str]:
1941
+ if transition == "start-execution":
1942
+ return ["prd.md", "design.md", "implement.md"]
1943
+ if transition == "full-task-complete":
1944
+ return ["prd.md", "design.md", "implement.md", "verify.md"]
1945
+ if transition == "child-review":
1946
+ return ["prd.md", "design.md", "implement.md", "verify.md", "handoff.md"]
1947
+ if transition in ("parent-accepted", "parent-integrated"):
1948
+ return ["task-map.md", "verify.md"]
1949
+ _ = gate
1950
+ return ["prd.md", "verify.md"]
1951
+
1952
+
1953
+ def _read_artifact_files(task_dir: Path, names: list[str]) -> list[dict]:
1954
+ files = []
1955
+ for name in names:
1956
+ path = task_dir / name
1957
+ try:
1958
+ content = path.read_text(encoding="utf-8") if path.is_file() else None
1959
+ except OSError:
1960
+ content = None
1961
+ files.append({"path": name, "content": content})
1962
+ return files
1963
+
1964
+
1965
+ def _required_file_errors(task_dir: Path, names: list[str]) -> list[str]:
1966
+ errors = []
1967
+ for name in names:
1968
+ path = task_dir / name
1969
+ if not path.is_file():
1970
+ errors.append(name)
1971
+ continue
1972
+ try:
1973
+ if not path.read_text(encoding="utf-8").strip():
1974
+ errors.append(f"{name} is empty")
1975
+ except OSError:
1976
+ errors.append(f"{name} could not be read")
1977
+ return errors
1978
+
1979
+
1980
+ def _get_gate_record(task_data: dict, transition: str, gate: str) -> dict | None:
1981
+ qgr = task_data.get("quality_gate_results")
1982
+ if not isinstance(qgr, dict):
1983
+ return None
1984
+ transitions = qgr.get("transitions")
1985
+ if not isinstance(transitions, dict):
1986
+ return None
1987
+ transition_records = transitions.get(transition)
1988
+ if not isinstance(transition_records, dict):
1989
+ return None
1990
+ record = transition_records.get(gate)
1991
+ return record if isinstance(record, dict) else None
1992
+
1993
+
1994
+ def _validate_gate_record_for_transition(
1995
+ task_data: dict,
1996
+ transition: str,
1997
+ gate: str,
1998
+ contract_fingerprint: str,
1999
+ artifact_fingerprint: str,
2000
+ ) -> list[str]:
2001
+ errors: list[str] = []
2002
+ record = _get_gate_record(task_data, transition, gate)
2003
+ if record is None:
2004
+ errors.append(f"missing gate record: {transition}/{gate}")
2005
+ return errors
2006
+
2007
+ result = record.get("result")
2008
+ if result == "FAIL":
2009
+ errors.append(f"gate failed: {transition}/{gate}")
2010
+ return errors
2011
+ if result not in ("PASS", "SKIPPED"):
2012
+ errors.append(f"gate record has invalid result: {transition}/{gate}")
2013
+ return errors
2014
+ if record.get("contract_fingerprint") != contract_fingerprint:
2015
+ errors.append(f"stale contract fingerprint: {transition}/{gate}")
2016
+ if record.get("artifact_fingerprint") != artifact_fingerprint:
2017
+ errors.append(f"stale artifact fingerprint: {transition}/{gate}")
2018
+ if result == "SKIPPED":
2019
+ approved_skip = record.get("approved_skip")
2020
+ if not isinstance(approved_skip, dict):
2021
+ errors.append(f"SKIPPED gate lacks approval metadata: {transition}/{gate}")
2022
+ elif approved_skip.get("approved_by") != "user" or not approved_skip.get("reason"):
2023
+ errors.append(f"invalid SKIPPED approval metadata: {transition}/{gate}")
2024
+ return errors
2025
+
2026
+
2027
+ def _next_consecutive_failures(
2028
+ previous: dict | None,
2029
+ issue_fingerprint: str | None,
2030
+ ) -> int:
2031
+ if not previous or previous.get("result") != "FAIL":
2032
+ return 1
2033
+ if previous.get("issue_fingerprint") != issue_fingerprint:
2034
+ return 1
2035
+ previous_count = previous.get("consecutive_failures")
2036
+ return int(previous_count) + 1 if isinstance(previous_count, int) else 2
2037
+
2038
+
2039
+ def _is_short_token(value: str | None, max_len: int = MAX_SHORT_FIELD) -> bool:
2040
+ if not value or not isinstance(value, str):
2041
+ return False
2042
+ if len(value) > max_len:
2043
+ return False
2044
+ if any(ch.isspace() for ch in value):
2045
+ return False
2046
+ return True
2047
+
2048
+
2049
+ def _is_short_text(value: str | None, max_len: int = MAX_SHORT_FIELD) -> bool:
2050
+ if not value or not isinstance(value, str):
2051
+ return False
2052
+ if len(value) > max_len:
2053
+ return False
2054
+ if "\n" in value or "\r" in value:
2055
+ return False
2056
+ return True