workflow-toolkit 1.0.1

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 (142) hide show
  1. package/.agents/skills/ponytail/SKILL.md +111 -0
  2. package/.agents/skills/ponytail-audit/SKILL.md +37 -0
  3. package/.agents/skills/ponytail-debt/SKILL.md +41 -0
  4. package/.agents/skills/ponytail-gain/SKILL.md +47 -0
  5. package/.agents/skills/ponytail-help/SKILL.md +70 -0
  6. package/.agents/skills/ponytail-review/SKILL.md +52 -0
  7. package/.agents/skills/prompt-review/SKILL.md +60 -0
  8. package/.agents/skills/wtk/SKILL.md +64 -0
  9. package/.agents/skills/wtk/references/artifacts.md +82 -0
  10. package/.agents/skills/wtk/references/evidence.md +68 -0
  11. package/.agents/skills/wtk/references/git.md +75 -0
  12. package/.agents/skills/wtk/references/test-contract.md +76 -0
  13. package/.agents/skills/wtk/references/validation.md +127 -0
  14. package/.agents/skills/wtk-config/SKILL.md +102 -0
  15. package/.agents/skills/wtk-config/assets/agents/claude/deep-reviewer.md +40 -0
  16. package/.agents/skills/wtk-config/assets/agents/claude/designer.md +57 -0
  17. package/.agents/skills/wtk-config/assets/agents/claude/explorer.md +41 -0
  18. package/.agents/skills/wtk-config/assets/agents/claude/implementer.md +57 -0
  19. package/.agents/skills/wtk-config/assets/agents/claude/planner.md +68 -0
  20. package/.agents/skills/wtk-config/assets/agents/claude/verifier.md +80 -0
  21. package/.agents/skills/wtk-config/assets/agents/codex/deep-reviewer.toml +41 -0
  22. package/.agents/skills/wtk-config/assets/agents/codex/designer.toml +55 -0
  23. package/.agents/skills/wtk-config/assets/agents/codex/explorer.toml +35 -0
  24. package/.agents/skills/wtk-config/assets/agents/codex/implementer.toml +52 -0
  25. package/.agents/skills/wtk-config/assets/agents/codex/planner.toml +66 -0
  26. package/.agents/skills/wtk-config/assets/agents/codex/verifier.toml +78 -0
  27. package/.agents/skills/wtk-config/assets/agents/cursor/deep-reviewer.md +38 -0
  28. package/.agents/skills/wtk-config/assets/agents/cursor/designer.md +55 -0
  29. package/.agents/skills/wtk-config/assets/agents/cursor/explorer.md +39 -0
  30. package/.agents/skills/wtk-config/assets/agents/cursor/implementer.md +55 -0
  31. package/.agents/skills/wtk-config/assets/agents/cursor/planner.md +66 -0
  32. package/.agents/skills/wtk-config/assets/agents/cursor/verifier.md +79 -0
  33. package/.agents/skills/wtk-config/scripts/ad-index.py +123 -0
  34. package/.agents/skills/wtk-config/scripts/repository_intelligence.py +671 -0
  35. package/.agents/skills/wtk-config/scripts/workflow_config.py +972 -0
  36. package/.agents/skills/wtk-deep-review/SKILL.md +164 -0
  37. package/.agents/skills/wtk-deep-review/assets/PROMPT.md +56 -0
  38. package/.agents/skills/wtk-deep-review/assets/REVIEW_UI.html +1340 -0
  39. package/.agents/skills/wtk-deep-review/assets/findings.schema.json +113 -0
  40. package/.agents/skills/wtk-deep-review/references/context-pack.md +88 -0
  41. package/.agents/skills/wtk-deep-review/references/orchestration.md +164 -0
  42. package/.agents/skills/wtk-deep-review/references/output-contracts.md +149 -0
  43. package/.agents/skills/wtk-deep-review/references/publish-github.md +83 -0
  44. package/.agents/skills/wtk-deep-review/references/state-and-learnings.md +64 -0
  45. package/.agents/skills/wtk-deep-review/references/subagent-runtimes.md +33 -0
  46. package/.agents/skills/wtk-deep-review/references/taxonomy.md +70 -0
  47. package/.agents/skills/wtk-deep-review/scripts/_common.py +371 -0
  48. package/.agents/skills/wtk-deep-review/scripts/build_jobs.py +561 -0
  49. package/.agents/skills/wtk-deep-review/scripts/build_knowledge.py +349 -0
  50. package/.agents/skills/wtk-deep-review/scripts/build_manifest.py +479 -0
  51. package/.agents/skills/wtk-deep-review/scripts/graft_context.py +160 -0
  52. package/.agents/skills/wtk-deep-review/scripts/graphify_context.py +75 -0
  53. package/.agents/skills/wtk-deep-review/scripts/merge_findings.py +313 -0
  54. package/.agents/skills/wtk-deep-review/scripts/render_html.py +240 -0
  55. package/.agents/skills/wtk-deep-review/scripts/render_review.py +282 -0
  56. package/.agents/skills/wtk-deep-review/scripts/run_jobs.py +436 -0
  57. package/.agents/skills/wtk-deep-review/scripts/token_metrics.py +369 -0
  58. package/.agents/skills/wtk-discover/SKILL.md +270 -0
  59. package/.agents/skills/wtk-discover/references/document-format.md +149 -0
  60. package/.agents/skills/wtk-implement/SKILL.md +95 -0
  61. package/.agents/skills/wtk-implement/references/checklist-format.md +116 -0
  62. package/.agents/skills/wtk-implement/references/screens.md +129 -0
  63. package/.agents/skills/wtk-implement/references/test-policy.md +152 -0
  64. package/.agents/skills/wtk-implement/references/verify.md +297 -0
  65. package/.agents/skills/wtk-knowledge-check/SKILL.md +16 -0
  66. package/.agents/skills/wtk-knowledge-check/scripts/check.ts +502 -0
  67. package/.agents/skills/wtk-knowledge-check/scripts/cli.ts +32 -0
  68. package/.agents/skills/wtk-knowledge-check/scripts/frontmatter.ts +44 -0
  69. package/.agents/skills/wtk-lean/.skill-meta.json +6 -0
  70. package/.agents/skills/wtk-lean/NOTICE.md +18 -0
  71. package/.agents/skills/wtk-lean/SKILL.md +137 -0
  72. package/.agents/skills/wtk-lean/references/build.md +168 -0
  73. package/.agents/skills/wtk-lean/references/checks.md +253 -0
  74. package/.agents/skills/wtk-lean/references/memory.md +156 -0
  75. package/.agents/skills/wtk-lean/references/plan.md +514 -0
  76. package/.agents/skills/wtk-lean/references/verify.md +336 -0
  77. package/.agents/skills/wtk-lean/scripts/check_commit.py +121 -0
  78. package/.agents/skills/wtk-lean/scripts/fixtures/checks.md +98 -0
  79. package/.agents/skills/wtk-lean/scripts/fixtures/plan.md +119 -0
  80. package/.agents/skills/wtk-lean/scripts/fixtures/verification.md +41 -0
  81. package/.agents/skills/wtk-lean/scripts/lessons.py +412 -0
  82. package/.agents/skills/wtk-lean/scripts/selftest.py +401 -0
  83. package/.agents/skills/wtk-lean/scripts/validate_checks.py +417 -0
  84. package/.agents/skills/wtk-lean/scripts/validate_plan.py +580 -0
  85. package/.agents/skills/wtk-lean/scripts/validate_verification.py +357 -0
  86. package/.agents/skills/wtk-plan/SKILL.md +102 -0
  87. package/.agents/skills/wtk-plan/references/document-format.md +222 -0
  88. package/.agents/skills/wtk-qa/SKILL.md +14 -0
  89. package/.agents/skills/wtk-qa-execute/SKILL.md +115 -0
  90. package/.agents/skills/wtk-qa-execute/references/fix-loop.md +23 -0
  91. package/.agents/skills/wtk-qa-execute/references/session-protocol.md +25 -0
  92. package/.agents/skills/wtk-qa-plan/SKILL.md +111 -0
  93. package/.agents/skills/wtk-qa-plan/references/profile.md +35 -0
  94. package/.agents/skills/wtk-ship/SKILL.md +49 -0
  95. package/.agents/skills/wtk-ship/remediation.py +170 -0
  96. package/.agents/skills/wtk-ship/scripts/close_feature.py +83 -0
  97. package/.agents/skills/wtk-ship/scripts/review_convergence.py +373 -0
  98. package/.wtk.toml.example +93 -0
  99. package/AGENTS.md +129 -0
  100. package/NOTICE.md +12 -0
  101. package/README.md +440 -0
  102. package/bin/wtk.js +25 -0
  103. package/docs/toolkit/README.md +56 -0
  104. package/docs/toolkit/decisions.md +61 -0
  105. package/docs/toolkit/guidelines/CONTEXT-BUDGET.md +64 -0
  106. package/docs/toolkit/guidelines/DX.md +67 -0
  107. package/docs/toolkit/guidelines/FRONTEND.md +73 -0
  108. package/docs/toolkit/guidelines/KNOWLEDGE-WIKI.md +128 -0
  109. package/docs/toolkit/guidelines/MODELING.md +134 -0
  110. package/docs/toolkit/guidelines/QA-EXECUTION.md +46 -0
  111. package/docs/toolkit/guidelines/QA-SCENARIOS.md +165 -0
  112. package/docs/toolkit/guidelines/REVIEW-ROUNDS.md +160 -0
  113. package/docs/toolkit/guidelines/SECURITY.md +160 -0
  114. package/docs/toolkit/guidelines/UI-UX.md +117 -0
  115. package/docs/toolkit/guidelines/WORKFLOW-MEMORY.md +73 -0
  116. package/docs/toolkit/guidelines.md +50 -0
  117. package/docs/toolkit/loop.md +77 -0
  118. package/docs/toolkit/purpose.md +51 -0
  119. package/docs/toolkit/repository-intelligence.md +53 -0
  120. package/docs/toolkit/reviews.md +77 -0
  121. package/knowledge/AGENTS.md +316 -0
  122. package/knowledge/raw/README.md +17 -0
  123. package/package.json +74 -0
  124. package/scripts/install_security_skills.py +816 -0
  125. package/scripts/installer/engine.js +254 -0
  126. package/scripts/installer/knowledge.js +28 -0
  127. package/scripts/installer/packets.js +77 -0
  128. package/scripts/installer/terminal.js +98 -0
  129. package/scripts/installer/transaction.js +75 -0
  130. package/skills-lock.json +92 -0
  131. package/templates/adoption/agents/core.md +23 -0
  132. package/templates/adoption/agents/quality.md +3 -0
  133. package/templates/adoption/knowledge/wiki/architecture/index.md +3 -0
  134. package/templates/adoption/knowledge/wiki/decisions/index.md +3 -0
  135. package/templates/adoption/knowledge/wiki/design/index.md +3 -0
  136. package/templates/adoption/knowledge/wiki/domain/index.md +3 -0
  137. package/templates/adoption/knowledge/wiki/index.md +3 -0
  138. package/templates/adoption/knowledge/wiki/log.md +3 -0
  139. package/templates/adoption/knowledge/wiki/open-questions/index.md +3 -0
  140. package/templates/adoption/knowledge/wiki/product/index.md +3 -0
  141. package/templates/adoption/knowledge/wiki/research/index.md +3 -0
  142. package/templates/adoption/product/AGENT-CONTEXT.md +25 -0
@@ -0,0 +1,972 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve the consumer workflow configuration and freeze feature state."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import re
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ try:
17
+ import tomllib
18
+ except ModuleNotFoundError: # pragma: no cover - Python 3.11 is the supported runtime.
19
+ tomllib = None
20
+
21
+
22
+ ROLES = ("planner", "implementer", "verifier", "explorer", "deep_reviewer", "designer")
23
+ DELEGATED_ROLES = ("implementer", "verifier", "explorer", "deep_reviewer", "designer")
24
+ PROVIDERS = ("claude", "codex", "cursor")
25
+ AGENT_NAMES = {"deep_reviewer": "deep-reviewer"}
26
+ EFFORTS = ("low", "medium", "high", "xhigh", "max", "ultra")
27
+ CADENCE_DEFAULT = "skip"
28
+ CADENCES = ("slice", "feature", "skip")
29
+ CADENCE_RE = re.compile(r"^grouped\.(\d+)$")
30
+ LEAN_PROFILES = ("light", "standard", "ui")
31
+ PROFILE_RE = re.compile(r"^\**Profile\**\s*:\s*`?(light|standard|ui)`?\s*$", re.IGNORECASE | re.MULTILINE)
32
+ SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
33
+ CONFIG_VERSION = 3
34
+ SNAPSHOT_VERSION = 3
35
+ PARALLELIZATION_DEFAULT = "disabled"
36
+ PARALLELIZATION_MODES = ("assisted", "disabled")
37
+ MAX_WORKERS_DEFAULT = "auto"
38
+ AUTOMATIC_BASELINE = 2
39
+ AUTOMATIC_CEILING = 4
40
+ CONFIG_KEYS = {"version", "deep_review", "parallelization", "profiles", "models", "remediation"}
41
+ DEEP_REVIEW_KEYS = {"cadence"}
42
+ PARALLELIZATION_KEYS = {"mode", "max_workers", "resource_provider"}
43
+ REMEDIATION_KEYS = {"stall_attempts"}
44
+ STALL_ATTEMPTS_DEFAULT = 3
45
+ MODEL_PROVIDERS = set(PROVIDERS)
46
+ MODEL_KEYS = {"model", "effort"}
47
+ MODEL_IDENTIFIER_RE = re.compile(r"^[^\\\s\[\]\"\x00-\x1f\x7f]+$")
48
+ FRONTMATTER_RE = re.compile(
49
+ r"\A---(?P<open_newline>\r\n|\n)(?P<header>.*?)(?P<close_newline>\r\n|\n)---(?P<after_newline>\r\n|\n|\Z)",
50
+ re.DOTALL,
51
+ )
52
+ CLAUDE_SKILLS_RE = re.compile(
53
+ r"^skills:(?P<inline>[^\r\n]*)(?P<block>(?:(?:\r\n|\n)[ \t]*-[^\r\n]*)*)",
54
+ re.MULTILINE,
55
+ )
56
+ CLAUDE_MODEL_RE = re.compile(
57
+ r"^model:[ \t]*(?P<model>[^\r\n]+?)(?P<suffix>[ \t]*)(?P<newline>\r\n|\n|\Z)",
58
+ re.MULTILINE,
59
+ )
60
+ CLAUDE_EFFORT_RE = re.compile(
61
+ r"^effort:[ \t]*(?P<effort>[^\r\n]+?)(?P<suffix>[ \t]*)(?P<newline>\r\n|\n|\Z)",
62
+ re.MULTILINE,
63
+ )
64
+ CODEX_ASSIGNMENT_RE = re.compile(
65
+ r'(?P<prefix>^[ \t]*(?P<key>model|model_reasoning_effort)[ \t]*=[ \t]*")'
66
+ r'(?P<raw>(?:\\.|[^"\\])*)(?P<closing>")'
67
+ r'(?P<suffix>[ \t]*(?:#[^\r\n]*)?)(?P<newline>\r\n|\n|\Z)',
68
+ re.MULTILINE,
69
+ )
70
+ CURSOR_MODEL_RE = re.compile(
71
+ r"^model:[ \t]*(?P<model>[^\r\n\[]+)\[effort=(?P<effort>[^\]\r\n]+)\](?P<suffix>[ \t]*)(?P<newline>\r\n|\n|\Z)",
72
+ re.MULTILINE,
73
+ )
74
+
75
+
76
+ class ConfigError(ValueError):
77
+ """A user-correctable workflow configuration error."""
78
+
79
+
80
+ def _error(message: str) -> ConfigError:
81
+ return ConfigError(f"wtk-config: {message}")
82
+
83
+
84
+ def _grouped_maximum(cadence: str) -> int | None:
85
+ """Validate a cadence; return N for grouped.N, None for a literal cadence."""
86
+ if cadence in CADENCES:
87
+ return None
88
+ match = CADENCE_RE.fullmatch(cadence)
89
+ if not match:
90
+ raise _error("cadence must be 'slice', 'feature', 'skip', or 'grouped.N'")
91
+ maximum = int(match.group(1))
92
+ if maximum < 1:
93
+ raise _error("grouped.N requires N to be at least 1")
94
+ return maximum
95
+
96
+
97
+ def balanced_groups(slice_count: int, cadence: str) -> list[list[int]]:
98
+ """Return consecutive, balanced 1-based slice groups for a cadence; `skip` has none."""
99
+ if slice_count < 1:
100
+ raise _error("slice count must be at least 1")
101
+ maximum = _grouped_maximum(cadence)
102
+ if cadence == "skip":
103
+ return []
104
+ if cadence == "slice":
105
+ return [[index] for index in range(1, slice_count + 1)]
106
+ if cadence == "feature":
107
+ return [list(range(1, slice_count + 1))]
108
+
109
+ group_count = (slice_count + maximum - 1) // maximum
110
+ base, remainder = divmod(slice_count, group_count)
111
+ sizes = [base + (1 if index < remainder else 0) for index in range(group_count)]
112
+ groups: list[list[int]] = []
113
+ next_slice = 1
114
+ for size in sizes:
115
+ groups.append(list(range(next_slice, next_slice + size)))
116
+ next_slice += size
117
+ return groups
118
+
119
+
120
+ def _load_config(path: Path, label: str) -> dict[str, Any]:
121
+ if tomllib is None: # pragma: no cover
122
+ raise _error(f"Python 3.11 or newer is required to parse {label}")
123
+ try:
124
+ with path.open("rb") as stream:
125
+ config = tomllib.load(stream)
126
+ except tomllib.TOMLDecodeError as exc:
127
+ raise _error(f"invalid {label}: {exc}") from exc
128
+ except FileNotFoundError as exc:
129
+ raise _error(f"{label} is missing") from exc
130
+ if not isinstance(config, dict):
131
+ raise _error(f"{label} must contain a table")
132
+ version = config.get("version")
133
+ if type(version) is not int or version != CONFIG_VERSION:
134
+ raise _error("version must be integer 3; refresh the project configuration")
135
+ _validate_config_schema(config)
136
+ return config
137
+
138
+
139
+ def _read_config(root: Path) -> dict[str, Any]:
140
+ path = root / ".wtk.toml"
141
+ if not path.exists():
142
+ raise _error("version must be integer 3; refresh the project configuration; .wtk.toml is missing")
143
+ return _load_config(path, ".wtk.toml")
144
+
145
+
146
+ def _cadence(config: dict[str, Any]) -> str:
147
+ section = config.get("deep_review") or {}
148
+ return section.get("cadence", CADENCE_DEFAULT)
149
+
150
+
151
+ def _resource_provider(root: Path, value: Any) -> str | None:
152
+ if value is None:
153
+ return None
154
+ if not isinstance(value, str) or not value:
155
+ raise _error("parallelization.resource_provider must be a non-empty path or null")
156
+ raw = Path(value)
157
+ if raw.is_absolute() or ".." in raw.parts:
158
+ raise _error("parallelization.resource_provider must stay inside the repository")
159
+ target = root.joinpath(*raw.parts)
160
+ current = root
161
+ for component in raw.parts:
162
+ current /= component
163
+ if current.is_symlink():
164
+ raise _error("parallelization.resource_provider cannot use a symlink")
165
+ resolved = target.resolve(strict=False)
166
+ try:
167
+ resolved.relative_to(root)
168
+ except ValueError as exc:
169
+ raise _error("parallelization.resource_provider must stay inside the repository") from exc
170
+ if not target.is_file():
171
+ raise _error("parallelization.resource_provider must be an executable file")
172
+ if not os.access(target, os.X_OK):
173
+ raise _error("parallelization.resource_provider must be executable")
174
+ return target.relative_to(root).as_posix()
175
+
176
+
177
+ def _parallelization(config: dict[str, Any], root: Path) -> dict[str, Any]:
178
+ section = config.get("parallelization") or {}
179
+ mode = section.get("mode", PARALLELIZATION_DEFAULT)
180
+ max_workers = section.get("max_workers", MAX_WORKERS_DEFAULT)
181
+ return {
182
+ "mode": mode,
183
+ "max_workers": max_workers,
184
+ "automatic_baseline": AUTOMATIC_BASELINE,
185
+ "automatic_ceiling": AUTOMATIC_CEILING,
186
+ "resource_provider": _resource_provider(root, section.get("resource_provider")),
187
+ }
188
+
189
+ def _stall_attempts(config: dict[str, Any]) -> int:
190
+ section = config.get("remediation") or {}
191
+ return section.get("stall_attempts", STALL_ATTEMPTS_DEFAULT)
192
+
193
+
194
+ def stall_attempts(root: Path) -> int:
195
+ """Read the current remediation stall threshold from the consumer config."""
196
+ return _stall_attempts(_read_config(root.resolve()))
197
+
198
+
199
+ def _profiles(config: dict[str, Any]) -> dict[str, dict[str, str]]:
200
+ return config.get("profiles") or {}
201
+
202
+
203
+ def _verification_profile(root: Path, feature: str, requested: str | None) -> str:
204
+ checks = root / ".specs" / "features" / feature / "checks.md"
205
+ approved = None
206
+ if checks.is_file():
207
+ approved = PROFILE_RE.search(checks.read_text(encoding="utf-8"))
208
+ approved = approved.group(1).lower() if approved else None
209
+ selected = requested or approved or "standard"
210
+ if selected not in LEAN_PROFILES:
211
+ raise _error("verification profile must be 'light', 'standard', or 'ui'")
212
+ if approved and selected != approved:
213
+ raise _error(
214
+ f"verification profile '{selected}' does not match checks.md profile '{approved}'"
215
+ )
216
+ return selected
217
+
218
+
219
+ def _validate_role_map(values: dict[str, Any], source: str) -> dict[str, str]:
220
+ result: dict[str, str] = {}
221
+ for role, provider in values.items():
222
+ if role not in DELEGATED_ROLES:
223
+ raise _error(f"{source} contains invalid role {role!r}")
224
+ if not isinstance(provider, str) or provider not in PROVIDERS:
225
+ raise _error(f"{source} role {role!r} has invalid provider {provider!r}")
226
+ result[role] = provider
227
+ return result
228
+
229
+
230
+ def _validate_config_schema(config: dict[str, Any]) -> None:
231
+ version = config.get("version")
232
+ if type(version) is not int or version != CONFIG_VERSION:
233
+ raise _error("version must be integer 3; refresh the project configuration")
234
+ unknown = set(config) - CONFIG_KEYS
235
+ if unknown:
236
+ raise _error(f"contains unknown top-level key {sorted(unknown)[0]!r}")
237
+
238
+ deep_review = config.get("deep_review", {})
239
+ if deep_review is None:
240
+ deep_review = {}
241
+ if not isinstance(deep_review, dict):
242
+ raise _error("deep_review must be a table")
243
+ unknown = set(deep_review) - DEEP_REVIEW_KEYS
244
+ if unknown:
245
+ raise _error(f"deep_review contains unknown key {sorted(unknown)[0]!r}")
246
+ cadence = deep_review.get("cadence", CADENCE_DEFAULT)
247
+ if not isinstance(cadence, str):
248
+ raise _error("deep_review.cadence must be a string")
249
+ _grouped_maximum(cadence)
250
+
251
+ parallelization = config.get("parallelization", {})
252
+ if parallelization is None:
253
+ parallelization = {}
254
+ if not isinstance(parallelization, dict):
255
+ raise _error("parallelization must be a table")
256
+ unknown = set(parallelization) - PARALLELIZATION_KEYS
257
+ if unknown:
258
+ raise _error(f"parallelization contains unknown key {sorted(unknown)[0]!r}")
259
+ mode = parallelization.get("mode", PARALLELIZATION_DEFAULT)
260
+ if not isinstance(mode, str) or mode not in PARALLELIZATION_MODES:
261
+ raise _error("parallelization.mode must be 'assisted' or 'disabled'")
262
+ max_workers = parallelization.get("max_workers", MAX_WORKERS_DEFAULT)
263
+ if max_workers != MAX_WORKERS_DEFAULT and (type(max_workers) is not int or max_workers < 1):
264
+ raise _error("parallelization.max_workers must be 'auto' or an integer of at least 1")
265
+
266
+ remediation = config.get("remediation", {})
267
+ if remediation is None:
268
+ remediation = {}
269
+ if not isinstance(remediation, dict):
270
+ raise _error("remediation must be a table")
271
+ unknown = set(remediation) - REMEDIATION_KEYS
272
+ if unknown:
273
+ raise _error(f"remediation contains unknown key {sorted(unknown)[0]!r}")
274
+ stall_attempts = remediation.get("stall_attempts", STALL_ATTEMPTS_DEFAULT)
275
+ if type(stall_attempts) is not int or stall_attempts < 0:
276
+ raise _error("remediation.stall_attempts must be an integer of at least 0")
277
+
278
+ profiles = config.get("profiles", {})
279
+ if profiles is None:
280
+ profiles = {}
281
+ if not isinstance(profiles, dict):
282
+ raise _error("profiles must be a table")
283
+ for name, values in profiles.items():
284
+ if not isinstance(name, str) or not isinstance(values, dict):
285
+ raise _error(f"profile {name!r} must be a table")
286
+ _validate_role_map(values, f"profile {name!r}")
287
+
288
+ models = config.get("models")
289
+ if not isinstance(models, dict):
290
+ raise _error("models must be a table containing every provider")
291
+ missing_providers = MODEL_PROVIDERS - set(models)
292
+ if missing_providers:
293
+ provider = sorted(missing_providers)[0]
294
+ raise _error(f"models.{provider} is required")
295
+ unknown_provider = set(models) - MODEL_PROVIDERS
296
+ if unknown_provider:
297
+ provider = sorted(unknown_provider)[0]
298
+ raise _error(f"models contains unknown provider {provider!r}")
299
+ for provider in PROVIDERS:
300
+ provider_values = models[provider]
301
+ if not isinstance(provider_values, dict):
302
+ raise _error(f"models.{provider} must be a table")
303
+ missing_roles = set(ROLES) - set(provider_values)
304
+ if missing_roles:
305
+ role = sorted(missing_roles)[0]
306
+ raise _error(f"models.{provider}.{role} is required")
307
+ unknown_roles = set(provider_values) - set(ROLES)
308
+ if unknown_roles:
309
+ role = sorted(unknown_roles)[0]
310
+ raise _error(f"models.{provider} contains unknown role {role!r}")
311
+ for role in ROLES:
312
+ setting = provider_values[role]
313
+ path = f"models.{provider}.{role}"
314
+ if not isinstance(setting, dict):
315
+ raise _error(f"{path} must be a table")
316
+ unknown = set(setting) - MODEL_KEYS
317
+ if unknown:
318
+ raise _error(f"{path} contains unknown key {sorted(unknown)[0]!r}")
319
+ if "model" not in setting:
320
+ raise _error(f"{path}.model is required")
321
+ model = setting["model"]
322
+ if not isinstance(model, str) or not model.strip():
323
+ raise _error(f"{path}.model must be a non-empty string")
324
+ if not MODEL_IDENTIFIER_RE.fullmatch(model):
325
+ raise _error(f"{path}.model must be a valid native model identifier")
326
+ if "effort" not in setting:
327
+ raise _error(f"{path}.effort is required")
328
+ effort = setting["effort"]
329
+ if not isinstance(effort, str) or effort not in EFFORTS:
330
+ allowed = ", ".join(EFFORTS)
331
+ raise _error(f"{path}.effort must be one of: {allowed}")
332
+ if provider == "claude" and effort == "ultra":
333
+ raise _error(f"{path}.effort 'ultra' is not supported by claude")
334
+
335
+
336
+ def _models(config: dict[str, Any]) -> dict[str, dict[str, dict[str, str]]]:
337
+ return config["models"]
338
+
339
+
340
+ def model_setting(config: dict[str, Any], provider: str, role: str) -> dict[str, str]:
341
+ """Return one validated provider-role model setting."""
342
+ try:
343
+ return _models(config)[provider][role]
344
+ except KeyError as exc: # pragma: no cover - callers load validated config.
345
+ raise _error(f"models.{provider}.{role} is required") from exc
346
+
347
+
348
+ def _one_match(pattern: re.Pattern[str], content: str, path: Path, label: str) -> re.Match[str]:
349
+ matches = list(pattern.finditer(content))
350
+ if len(matches) != 1:
351
+ raise _error(f"{path.as_posix()} must contain exactly one {label} metadata field")
352
+ return matches[0]
353
+
354
+
355
+ def _header(provider: str, content: str, path: Path) -> tuple[str, int]:
356
+ if provider in ("claude", "cursor"):
357
+ match = FRONTMATTER_RE.match(content)
358
+ if not match:
359
+ raise _error(f"{path.as_posix()} must contain a native YAML frontmatter header")
360
+ return match.group("header"), match.start("header")
361
+ boundary = re.search(r"^developer_instructions[ \t]*=", content, re.MULTILINE)
362
+ end = boundary.start() if boundary else len(content)
363
+ return content[:end], 0
364
+
365
+
366
+ def _preload_skills(header: str) -> list[str]:
367
+ """Skill names from a Claude `skills:` inline or block list."""
368
+ match = CLAUDE_SKILLS_RE.search(header)
369
+ if not match:
370
+ return []
371
+ inline = match.group("inline").strip().strip("[]")
372
+ entries = inline.split(",") if inline else []
373
+ entries += [line.strip().lstrip("-") for line in match.group("block").splitlines()]
374
+ return [name for name in (entry.strip().strip("\"'") for entry in entries) if name]
375
+
376
+
377
+ def _coerce_content(content: str | bytes) -> tuple[str, bool]:
378
+ if isinstance(content, bytes):
379
+ return content.decode("utf-8"), True
380
+ return content, False
381
+
382
+
383
+ def _toml_basic_value(value: str) -> str:
384
+ """Escape a value for an existing TOML basic-string quote pair."""
385
+ encoded = json.dumps(value, ensure_ascii=False)
386
+ return encoded[1:-1]
387
+
388
+
389
+ def _toml_prefix_is_top_level(prefix: str) -> bool:
390
+ if tomllib is None: # pragma: no cover
391
+ return False
392
+ try:
393
+ parsed = tomllib.loads(prefix + '\n__workflow_top_level_probe = ""\n')
394
+ except tomllib.TOMLDecodeError:
395
+ return False
396
+ return parsed.get("__workflow_top_level_probe") == ""
397
+
398
+
399
+ def _codex_developer_boundary(content: str, path: Path) -> int:
400
+ candidates = [
401
+ match for match in re.finditer(r"^[ \t]*developer_instructions[ \t]*=", content, re.MULTILINE)
402
+ if _toml_prefix_is_top_level(content[:match.start()])
403
+ ]
404
+ if len(candidates) != 1:
405
+ raise _error(f"{path.as_posix()} must contain exactly one top-level developer_instructions assignment")
406
+ return candidates[0].start()
407
+
408
+
409
+ def _codex_fields(content: str, path: Path) -> dict[str, list[tuple[int, int, re.Match[str], str]]]:
410
+ if tomllib is None: # pragma: no cover
411
+ raise _error("Python 3.11 or newer is required to parse Codex agent packets")
412
+ try:
413
+ parsed = tomllib.loads(content)
414
+ except tomllib.TOMLDecodeError as exc:
415
+ raise _error(f"{path.as_posix()} contains invalid TOML: {exc}") from exc
416
+ boundary = _codex_developer_boundary(content, path)
417
+ fields: dict[str, list[tuple[int, int, re.Match[str], str]]] = {"model": [], "effort": []}
418
+ prefix = content[:boundary]
419
+ for match in CODEX_ASSIGNMENT_RE.finditer(prefix):
420
+ if not _toml_prefix_is_top_level(prefix[:match.start()]):
421
+ continue
422
+ try:
423
+ line_parsed = tomllib.loads(content[match.start():match.end()])
424
+ except tomllib.TOMLDecodeError:
425
+ continue
426
+ key = match.group("key")
427
+ if key in line_parsed:
428
+ field = "model" if key == "model" else "effort"
429
+ fields[field].append((match.start(), match.end(), match, line_parsed[key]))
430
+ for field, key in (("model", "model"), ("effort", "model_reasoning_effort")):
431
+ if len(fields[field]) != 1 or parsed.get(key) != fields[field][0][3]:
432
+ fields[field] = []
433
+ return fields
434
+
435
+
436
+ def _codex_field(
437
+ fields: dict[str, list[tuple[int, int, re.Match[str], str]]], key: str, path: Path, label: str
438
+ ) -> tuple[int, int, re.Match[str], str]:
439
+ matches = fields[key]
440
+ if len(matches) != 1:
441
+ raise _error(f"{path.as_posix()} must contain exactly one {label} metadata field")
442
+ return matches[0]
443
+
444
+
445
+ def packet_setting(provider: str, content: str | bytes, path: Path) -> dict[str, str]:
446
+ """Parse the native model and effort metadata from one packet."""
447
+ text, _ = _coerce_content(content)
448
+ header, _ = _header(provider, text, path)
449
+ if provider == "claude":
450
+ model = _one_match(CLAUDE_MODEL_RE, header, path, "model").group("model").strip()
451
+ effort = _one_match(CLAUDE_EFFORT_RE, header, path, "effort").group("effort").strip()
452
+ elif provider == "codex":
453
+ fields = _codex_fields(text, path)
454
+ _, _, _, model = _codex_field(fields, "model", path, "model")
455
+ _, _, _, effort = _codex_field(fields, "effort", path, "model_reasoning_effort")
456
+ else:
457
+ match = _one_match(CURSOR_MODEL_RE, header, path, "model/effort").groupdict()
458
+ model, effort = match["model"].strip(), match["effort"].strip()
459
+ if not MODEL_IDENTIFIER_RE.fullmatch(model):
460
+ raise _error(f"{path.as_posix()} contains an invalid model identifier")
461
+ return {"model": model, "effort": effort}
462
+
463
+
464
+ def render_agent_packet(
465
+ provider: str, content: str | bytes, setting: dict[str, str], path: Path | None = None
466
+ ) -> str | bytes:
467
+ """Replace only provider-native model metadata in an agent packet."""
468
+ packet_path = path or Path("agent packet")
469
+ text, as_bytes = _coerce_content(content)
470
+ header, header_offset = _header(provider, text, packet_path)
471
+ if provider == "claude":
472
+ model_pattern, effort_pattern = CLAUDE_MODEL_RE, CLAUDE_EFFORT_RE
473
+ else:
474
+ model_pattern, effort_pattern = CURSOR_MODEL_RE, None
475
+
476
+ if provider == "codex":
477
+ fields = _codex_fields(text, packet_path)
478
+ model_start, model_end, model_match, _ = _codex_field(fields, "model", packet_path, "model")
479
+ model_replacement = (
480
+ text[model_start:model_match.start("raw")]
481
+ + _toml_basic_value(setting["model"])
482
+ + text[model_match.end("raw"):model_end]
483
+ )
484
+ rendered = text[:model_start] + model_replacement + text[model_end:]
485
+ new_fields = _codex_fields(rendered, packet_path)
486
+ effort_start, effort_end, new_effort_match, _ = _codex_field(new_fields, "effort", packet_path, "model_reasoning_effort")
487
+ effort_replacement = (
488
+ rendered[effort_start:new_effort_match.start("raw")]
489
+ + _toml_basic_value(setting["effort"])
490
+ + rendered[new_effort_match.end("raw"):effort_end]
491
+ )
492
+ rendered = rendered[:effort_start] + effort_replacement + rendered[effort_end:]
493
+ return rendered.encode("utf-8") if as_bytes else rendered
494
+ model_match = _one_match(model_pattern, header, packet_path, "model")
495
+ if provider == "cursor":
496
+ replacement = (
497
+ f"model: {setting['model']}[effort={setting['effort']}]"
498
+ f"{model_match.group('suffix')}{model_match.group('newline')}"
499
+ )
500
+ start = header_offset + model_match.start()
501
+ end = header_offset + model_match.end()
502
+ rendered = text[:start] + replacement + text[end:]
503
+ else:
504
+ effort_match = _one_match(effort_pattern, header, packet_path, "effort")
505
+ model_value = f"model: {setting['model']}"
506
+ model_replacement = model_value + model_match.group("suffix") + model_match.group("newline")
507
+ start = header_offset + model_match.start()
508
+ end = header_offset + model_match.end()
509
+ rendered = text[:start] + model_replacement + text[end:]
510
+ # Re-find the effort field after replacing the model so offsets remain valid.
511
+ new_header, new_header_offset = _header(provider, rendered, packet_path)
512
+ effort_match = _one_match(effort_pattern, new_header, packet_path, "effort")
513
+ effort_value = (
514
+ f"effort: {setting['effort']}" if provider == "claude"
515
+ else f"effort: {setting['effort']}"
516
+ )
517
+ effort_replacement = effort_value + effort_match.group("suffix") + effort_match.group("newline")
518
+ start = new_header_offset + effort_match.start()
519
+ end = new_header_offset + effort_match.end()
520
+ rendered = rendered[:start] + effort_replacement + rendered[end:]
521
+ return rendered.encode("utf-8") if as_bytes else rendered
522
+
523
+
524
+ def _write_bytes_atomic(path: Path, content: bytes) -> None:
525
+ temporary: str | None = None
526
+ try:
527
+ with tempfile.NamedTemporaryFile(mode="wb", dir=path.parent, prefix=f".{path.name}.", delete=False) as stream:
528
+ temporary = stream.name
529
+ stream.write(content)
530
+ stream.flush()
531
+ os.fsync(stream.fileno())
532
+ os.replace(temporary, path)
533
+ temporary = None
534
+ finally:
535
+ if temporary:
536
+ try:
537
+ os.unlink(temporary)
538
+ except FileNotFoundError:
539
+ pass
540
+
541
+
542
+ def _preflight_destination(root: Path, destination: Path, label: str) -> None:
543
+ relative = destination.relative_to(root).as_posix()
544
+ parent = destination.parent
545
+ while parent != root:
546
+ if parent.is_symlink():
547
+ parent_relative = parent.relative_to(root).as_posix()
548
+ raise _error(f"{label} parent {parent_relative} must not be a symlink")
549
+ if parent.exists() and not parent.is_dir():
550
+ parent_relative = parent.relative_to(root).as_posix()
551
+ raise _error(f"{label} parent {parent_relative} must be a directory")
552
+ parent = parent.parent
553
+ if destination.is_symlink():
554
+ raise _error(f"{label} destination {relative} must not be a symlink")
555
+ if destination.exists() and not destination.is_file():
556
+ raise _error(f"{label} destination {relative} must be a file")
557
+
558
+
559
+ def _preflight_path(root: Path, path: Path, label: str) -> None:
560
+ relative = path.relative_to(root)
561
+ current = root
562
+ for part in relative.parts:
563
+ current /= part
564
+ if current.is_symlink():
565
+ current_relative = current.relative_to(root).as_posix()
566
+ relation = "path" if current == path else "parent"
567
+ raise _error(f"{label} {relation} {current_relative} must not be a symlink")
568
+
569
+
570
+ def _sync_config(root: Path) -> tuple[dict[str, Any], bytes | None]:
571
+ local = root / ".wtk.toml"
572
+ _preflight_path(root, local, "local config")
573
+ if local.exists():
574
+ return _read_config(root), None
575
+ example = root / ".wtk.toml.example"
576
+ _preflight_path(root, example, "config example")
577
+ config = _load_config(example, ".wtk.toml.example")
578
+ return config, example.read_bytes()
579
+
580
+
581
+ def sync_agents(root: Path) -> dict[str, list[str]]:
582
+ """Validate templates and materialize complete ignored runtime packets."""
583
+ root = root.absolute()
584
+ if root.is_symlink():
585
+ raise _error(f"root {root} must not be a symlink")
586
+ if not root.is_dir():
587
+ raise _error(f"root is not a directory: {root}")
588
+ local_config = root / ".wtk.toml"
589
+ _preflight_path(root, local_config, "local config")
590
+ _preflight_destination(root, local_config, "local config")
591
+ config, config_bytes = _sync_config(root)
592
+ plans: list[tuple[Path, bytes]] = []
593
+ for provider in PROVIDERS:
594
+ for role in ROLES:
595
+ relative = _runtime_relative(provider, role)
596
+ template_relative = _template_relative(provider, role)
597
+ template_path = root / template_relative
598
+ _preflight_path(root, template_path, "agent template")
599
+ try:
600
+ template = template_path.read_bytes()
601
+ except FileNotFoundError as exc:
602
+ raise _error(f"missing agent template {template_relative.as_posix()}") from exc
603
+ packet_setting(provider, template, template_relative)
604
+ if provider == "claude":
605
+ header, _ = _header(provider, template.decode("utf-8"), template_relative)
606
+ for skill in _preload_skills(header):
607
+ if not (root / ".agents" / "skills" / skill / "SKILL.md").is_file():
608
+ raise _error(
609
+ f"{template_relative.as_posix()} preloads unknown skill {skill!r}"
610
+ )
611
+ rendered = render_agent_packet(
612
+ provider, template, model_setting(config, provider, role), template_relative
613
+ )
614
+ assert isinstance(rendered, bytes)
615
+ plans.append((relative, rendered))
616
+ for relative, _ in plans:
617
+ _preflight_destination(root, root / relative, "runtime")
618
+ if config_bytes is not None:
619
+ _write_bytes_atomic(local_config, config_bytes)
620
+ changed: list[str] = []
621
+ unchanged: list[str] = []
622
+ for relative, rendered in plans:
623
+ path = root / relative
624
+ current = path.read_bytes() if path.exists() else None
625
+ if current == rendered:
626
+ unchanged.append(relative.as_posix())
627
+ else:
628
+ path.parent.mkdir(parents=True, exist_ok=True)
629
+ _write_bytes_atomic(path, rendered)
630
+ changed.append(relative.as_posix())
631
+ return {"changed": changed, "unchanged": unchanged}
632
+
633
+
634
+ def _parse_overrides(values: list[str]) -> dict[str, str]:
635
+ parsed: dict[str, str] = {}
636
+ for value in values:
637
+ role, separator, provider = value.partition("=")
638
+ if not separator or not role or not provider:
639
+ raise _error("override must use role=provider")
640
+ if role in parsed:
641
+ raise _error(f"duplicate override for role {role!r}")
642
+ parsed.update(_validate_role_map({role: provider}, "override"))
643
+ return parsed
644
+
645
+
646
+ def _git_head(root: Path) -> str:
647
+ try:
648
+ return subprocess.check_output(
649
+ ["git", "rev-parse", "HEAD"], cwd=root, text=True, stderr=subprocess.PIPE
650
+ ).strip()
651
+ except (OSError, subprocess.CalledProcessError) as exc:
652
+ raise _error(f"cannot resolve git head in {root}") from exc
653
+
654
+
655
+ def _runtime_relative(provider: str, role: str) -> Path:
656
+ extension = "toml" if provider == "codex" else "md"
657
+ agent_name = AGENT_NAMES.get(role, role)
658
+ return Path(f".{provider}") / "agents" / f"{agent_name}.{extension}"
659
+
660
+
661
+ def _template_relative(provider: str, role: str) -> Path:
662
+ extension = "toml" if provider == "codex" else "md"
663
+ agent_name = AGENT_NAMES.get(role, role)
664
+ return Path(".agents") / "skills" / "wtk-config" / "assets" / "agents" / provider / f"{agent_name}.{extension}"
665
+
666
+
667
+ def _agent_file(root: Path, provider: str, role: str) -> str:
668
+ relative = _runtime_relative(provider, role)
669
+ if (root / relative).is_file():
670
+ return relative.as_posix()
671
+ raise _error(
672
+ f"missing generated agent file for provider {provider!r}, role {role!r}; "
673
+ f"run --sync-agents; expected {relative.as_posix()}"
674
+ )
675
+
676
+
677
+ def _agent_candidates(provider: str, role: str) -> tuple[Path, ...]:
678
+ return (_runtime_relative(provider, role),)
679
+
680
+
681
+ def _snapshot_path(root: Path, feature: str) -> Path:
682
+ if not SLUG_RE.fullmatch(feature):
683
+ raise _error("feature must be a lowercase slug")
684
+ return root / ".specs" / "features" / feature / "workflow.json"
685
+
686
+
687
+ def _derived_slice_count(root: Path, feature: str) -> int:
688
+ """Read whole-slice headings from the integrated Lean checks artifact."""
689
+ checks = root / ".specs" / "features" / feature / "checks.md"
690
+ if checks.is_file():
691
+ count = sum(1 for line in checks.read_text(encoding="utf-8").splitlines()
692
+ if re.match(r"^### S\d+\s+-", line))
693
+ return count or 1
694
+ return 1
695
+
696
+ def _validate_snapshot(root: Path, feature: str, snapshot: Any) -> dict[str, Any]:
697
+ if not isinstance(snapshot, dict):
698
+ raise _error("existing snapshot must be a JSON object")
699
+ if type(snapshot.get("version")) is not int or snapshot.get("version") != SNAPSHOT_VERSION:
700
+ if snapshot.get("version") in (1, 2):
701
+ raise _error("workflow snapshot version is stale; rerun resolution with --refresh")
702
+ raise _error("existing snapshot version must be integer 3")
703
+ required = {
704
+ "version",
705
+ "feature",
706
+ "git_head",
707
+ "profile",
708
+ "verification_profile",
709
+ "overrides",
710
+ "deep_review",
711
+ "parallelization",
712
+ "roles",
713
+ }
714
+ if set(snapshot) != required:
715
+ raise _error("existing snapshot has an incomplete schema")
716
+ if snapshot["feature"] != feature or not isinstance(snapshot["feature"], str):
717
+ raise _error("existing snapshot feature does not match the requested feature")
718
+ if not isinstance(snapshot["git_head"], str) or not snapshot["git_head"]:
719
+ raise _error("existing snapshot git_head must be a non-empty string")
720
+ if snapshot["profile"] is not None and not isinstance(snapshot["profile"], str):
721
+ raise _error("existing snapshot profile must be a string or null")
722
+ verification_profile = snapshot["verification_profile"]
723
+ if verification_profile not in LEAN_PROFILES:
724
+ raise _error("existing snapshot verification_profile is invalid")
725
+ if verification_profile != _verification_profile(root, feature, verification_profile):
726
+ raise _error("existing snapshot verification_profile does not match checks.md")
727
+ overrides = snapshot["overrides"]
728
+ if not isinstance(overrides, dict):
729
+ raise _error("existing snapshot overrides must be a table")
730
+ _validate_role_map(overrides, "existing snapshot overrides")
731
+
732
+ deep_review = snapshot["deep_review"]
733
+ if not isinstance(deep_review, dict) or set(deep_review) != {"cadence", "groups"}:
734
+ raise _error("existing snapshot deep_review has an incomplete schema")
735
+ cadence = deep_review["cadence"]
736
+ if not isinstance(cadence, str):
737
+ raise _error("existing snapshot deep_review.cadence must be a string")
738
+ groups = deep_review["groups"]
739
+ if not isinstance(groups, list) or any(
740
+ not isinstance(group, list)
741
+ or not group
742
+ or any(type(index) is not int or index < 1 for index in group)
743
+ for group in groups
744
+ ):
745
+ raise _error("existing snapshot deep_review.groups must be a list of non-empty integer lists")
746
+ flattened = [index for group in groups for index in group]
747
+ if flattened != list(range(1, len(flattened) + 1)):
748
+ raise _error("existing snapshot deep_review.groups must be consecutive")
749
+ if balanced_groups(max(len(flattened), 1), cadence) != groups:
750
+ raise _error("existing snapshot deep_review.groups do not match cadence")
751
+
752
+ parallelization = snapshot["parallelization"]
753
+ snapshot_parallelization_keys = {
754
+ "mode", "max_workers", "automatic_baseline", "automatic_ceiling", "resource_provider"
755
+ }
756
+ if not isinstance(parallelization, dict) or set(parallelization) != snapshot_parallelization_keys:
757
+ raise _error("existing snapshot parallelization has an incomplete schema")
758
+ mode = parallelization["mode"]
759
+ if not isinstance(mode, str) or mode not in PARALLELIZATION_MODES:
760
+ raise _error("existing snapshot parallelization.mode is invalid")
761
+ max_workers = parallelization["max_workers"]
762
+ if max_workers != MAX_WORKERS_DEFAULT and (type(max_workers) is not int or max_workers < 1):
763
+ raise _error("existing snapshot parallelization.max_workers is invalid")
764
+ if parallelization["automatic_baseline"] != AUTOMATIC_BASELINE:
765
+ raise _error("existing snapshot parallelization.automatic_baseline is invalid")
766
+ if parallelization["automatic_ceiling"] != AUTOMATIC_CEILING:
767
+ raise _error("existing snapshot parallelization.automatic_ceiling is invalid")
768
+ normalized_provider = _resource_provider(root, parallelization.get("resource_provider"))
769
+
770
+ roles = snapshot["roles"]
771
+ if not isinstance(roles, dict) or set(roles) != set(DELEGATED_ROLES):
772
+ raise _error("existing snapshot roles must contain every delegated workflow role")
773
+ for role in DELEGATED_ROLES:
774
+ route = roles[role]
775
+ if not isinstance(route, dict) or set(route) != {"provider", "agent_file", "model", "effort"}:
776
+ raise _error(f"existing snapshot role {role!r} has an incomplete schema")
777
+ provider = route["provider"]
778
+ if not isinstance(provider, str) or provider not in PROVIDERS:
779
+ raise _error(f"existing snapshot role {role!r} has an invalid provider")
780
+ agent_file = route["agent_file"]
781
+ if not isinstance(agent_file, str) or not agent_file:
782
+ raise _error(f"existing snapshot role {role!r} agent_file must be a string")
783
+ allowed_paths = _agent_candidates(provider, role)
784
+ if agent_file not in {path.as_posix() for path in allowed_paths}:
785
+ raise _error(f"existing snapshot role {role!r} has an invalid agent_file")
786
+ if not (root / agent_file).is_file():
787
+ raise _error(f"existing snapshot role {role!r} agent_file is missing")
788
+ model = route["model"]
789
+ if not isinstance(model, str) or not model:
790
+ raise _error(f"existing snapshot role {role!r} model must be a non-empty string")
791
+ effort = route["effort"]
792
+ if not isinstance(effort, str) or effort not in EFFORTS:
793
+ raise _error(f"existing snapshot role {role!r} effort is invalid")
794
+ current = packet_setting(provider, (root / agent_file).read_bytes(), Path(agent_file))
795
+ if current != {"model": model, "effort": effort}:
796
+ raise _error(
797
+ f"role {role!r} packet metadata differs from frozen snapshot; "
798
+ "run --sync-agents, then explicitly use --refresh"
799
+ )
800
+ normalized = dict(snapshot)
801
+ normalized["parallelization"] = {
802
+ "mode": mode,
803
+ "max_workers": max_workers,
804
+ "automatic_baseline": AUTOMATIC_BASELINE,
805
+ "automatic_ceiling": AUTOMATIC_CEILING,
806
+ "resource_provider": normalized_provider,
807
+ }
808
+ return normalized
809
+
810
+
811
+ def validate_snapshot(root: Path, feature: str, snapshot: Any) -> dict[str, Any]:
812
+ """Validate the complete frozen v3 snapshot for all runtime readers."""
813
+ return _validate_snapshot(root.resolve(), feature, snapshot)
814
+
815
+
816
+ def _write_snapshot(path: Path, snapshot: dict[str, Any]) -> None:
817
+ path.parent.mkdir(parents=True, exist_ok=True)
818
+ temporary: str | None = None
819
+ try:
820
+ with tempfile.NamedTemporaryFile(
821
+ mode="w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
822
+ ) as stream:
823
+ temporary = stream.name
824
+ json.dump(snapshot, stream, indent=2, sort_keys=True)
825
+ stream.write("\n")
826
+ stream.flush()
827
+ os.fsync(stream.fileno())
828
+ os.replace(temporary, path)
829
+ temporary = None
830
+ finally:
831
+ if temporary:
832
+ try:
833
+ os.unlink(temporary)
834
+ except FileNotFoundError:
835
+ pass
836
+
837
+
838
+ def resolve(
839
+ *,
840
+ root: Path,
841
+ feature: str,
842
+ native_provider: str,
843
+ slice_count: int | None = None,
844
+ profile: str | None = None,
845
+ verification_profile: str | None = None,
846
+ overrides: list[str] | None = None,
847
+ refresh: bool = False,
848
+ ) -> dict[str, Any]:
849
+ """Resolve and persist one feature's effective workflow route."""
850
+ root = root.resolve()
851
+ if not root.is_dir():
852
+ raise _error(f"root is not a directory: {root}")
853
+ if native_provider not in PROVIDERS:
854
+ raise _error(f"invalid native provider {native_provider!r}")
855
+ config = _read_config(root)
856
+ remediation = {"stall_attempts": _stall_attempts(config)}
857
+ snapshot_path = _snapshot_path(root, feature)
858
+ if snapshot_path.exists() and not refresh:
859
+ try:
860
+ with snapshot_path.open(encoding="utf-8") as stream:
861
+ snapshot = json.load(stream)
862
+ except (OSError, json.JSONDecodeError) as exc:
863
+ raise _error(f"existing snapshot is invalid: {snapshot_path}") from exc
864
+ resolved = dict(_validate_snapshot(root, feature, snapshot))
865
+ resolved["remediation"] = remediation
866
+ return resolved
867
+
868
+ derived_count = _derived_slice_count(root, feature)
869
+ if slice_count is not None:
870
+ if slice_count < 1:
871
+ raise _error("slice count must be at least 1")
872
+ if slice_count != derived_count:
873
+ raise _error(
874
+ f"slice count assertion {slice_count} does not match derived slice count {derived_count}"
875
+ )
876
+ slice_count = derived_count
877
+ verification_profile = _verification_profile(root, feature, verification_profile)
878
+ cadence = _cadence(config)
879
+ parallelization = _parallelization(config, root)
880
+ groups = balanced_groups(slice_count, cadence)
881
+ profiles = _profiles(config)
882
+ if profile is not None and profile not in profiles:
883
+ raise _error(f"unknown profile {profile!r}")
884
+ selected = profiles.get(profile, {})
885
+ parsed_overrides = _parse_overrides(overrides or [])
886
+ providers = {role: parsed_overrides.get(role, selected.get(role, native_provider)) for role in ROLES}
887
+ for provider in PROVIDERS:
888
+ for role in ROLES:
889
+ agent_file = _agent_file(root, provider, role)
890
+ current = packet_setting(provider, (root / agent_file).read_bytes(), Path(agent_file))
891
+ expected = model_setting(config, provider, role)
892
+ if current != expected:
893
+ raise _error(
894
+ f"{agent_file} is not synchronized with models.{provider}.{role}; "
895
+ "run --sync-agents before resolving"
896
+ )
897
+ roles = {}
898
+ for role in DELEGATED_ROLES:
899
+ provider = providers[role]
900
+ agent_file = _agent_file(root, provider, role)
901
+ current = packet_setting(provider, (root / agent_file).read_bytes(), Path(agent_file))
902
+ expected = model_setting(config, provider, role)
903
+ if current != expected:
904
+ raise _error(
905
+ f"{agent_file} is not synchronized with models.{provider}.{role}; "
906
+ "run --sync-agents before resolving"
907
+ )
908
+ roles[role] = {
909
+ "provider": provider,
910
+ "agent_file": agent_file,
911
+ "model": expected["model"],
912
+ "effort": expected["effort"],
913
+ }
914
+ snapshot = {
915
+ "version": SNAPSHOT_VERSION,
916
+ "feature": feature,
917
+ "git_head": _git_head(root),
918
+ "profile": profile,
919
+ "verification_profile": verification_profile,
920
+ "overrides": parsed_overrides,
921
+ "deep_review": {"cadence": cadence, "groups": groups},
922
+ "parallelization": parallelization,
923
+ "roles": roles,
924
+ }
925
+ _write_snapshot(snapshot_path, snapshot)
926
+ resolved = dict(snapshot)
927
+ resolved["remediation"] = remediation
928
+ return resolved
929
+
930
+
931
+ def _parser() -> argparse.ArgumentParser:
932
+ parser = argparse.ArgumentParser(description=__doc__)
933
+ parser.add_argument("--root", type=Path, default=Path.cwd())
934
+ parser.add_argument("--feature")
935
+ parser.add_argument("--slices", dest="slice_count", type=int)
936
+ parser.add_argument("--native-provider")
937
+ parser.add_argument("--profile")
938
+ parser.add_argument("--verification-profile")
939
+ parser.add_argument("--override", dest="overrides", action="append", default=[])
940
+ parser.add_argument("--refresh", action="store_true")
941
+ parser.add_argument("--sync-agents", action="store_true")
942
+ return parser
943
+
944
+
945
+ def main(argv: list[str] | None = None) -> int:
946
+ arguments = _parser().parse_args(argv)
947
+ try:
948
+ if arguments.sync_agents:
949
+ if any(
950
+ value is not None
951
+ for value in (arguments.feature, arguments.slice_count, arguments.native_provider,
952
+ arguments.profile, arguments.verification_profile)
953
+ ) or arguments.refresh:
954
+ raise _error("--sync-agents cannot be combined with feature-resolution arguments")
955
+ if arguments.overrides:
956
+ raise _error("--sync-agents cannot be combined with feature-resolution arguments")
957
+ print(json.dumps(sync_agents(arguments.root), indent=2, sort_keys=True))
958
+ return 0
959
+ if arguments.feature is None or arguments.native_provider is None:
960
+ raise _error("--feature and --native-provider are required unless --sync-agents is used")
961
+ values = vars(arguments)
962
+ values.pop("sync_agents")
963
+ snapshot = resolve(**values)
964
+ except ConfigError as exc:
965
+ print(str(exc), file=sys.stderr)
966
+ return 2
967
+ print(json.dumps(snapshot, indent=2, sort_keys=True))
968
+ return 0
969
+
970
+
971
+ if __name__ == "__main__":
972
+ raise SystemExit(main())