@danmoisan/drm-copilot-mcp 0.0.5 → 0.0.6

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 (47) hide show
  1. package/out/mcp-server.js +93 -20
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/orchestrator.md +6 -2
  4. package/resources/claude-customizations/.claude/agents/pr-author.md +78 -0
  5. package/resources/claude-customizations/.claude/agents/task-researcher.md +10 -6
  6. package/resources/claude-customizations/.claude/hooks/enforce-checkpoint-monotonic.ps1 +68 -10
  7. package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +28 -1
  8. package/resources/claude-customizations/.claude/hooks/enforce-evidence-locations.ps1 +40 -14
  9. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +210 -0
  10. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +153 -10
  11. package/resources/claude-customizations/.claude/hooks/validate-pr-author-output.ps1 +136 -0
  12. package/resources/claude-customizations/.claude/hooks/validate-task-researcher-output.ps1 +22 -6
  13. package/resources/claude-customizations/.claude/settings.json +22 -0
  14. package/resources/claude-customizations/.claude/skills/evidence-and-timestamp-conventions/SKILL.md +0 -1
  15. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +76 -2
  16. package/resources/claude-customizations/.claude/skills/research-issue/SKILL.md +5 -3
  17. package/resources/claude-customizations/.claude-variants/csharp-legacy/agents/csharp-typed-engineer.md +69 -0
  18. package/resources/claude-customizations/.claude-variants/csharp-legacy/rules/csharp.md +96 -0
  19. package/resources/claude-customizations/.claude-variants/csharp-legacy/skills/csharp-qa-gate/SKILL.md +77 -0
  20. package/resources/claude-customizations/.claude-variants/csharp-legacy/skills/invoke-csharp-engineer/SKILL.md +64 -0
  21. package/resources/claude-customizations/pack-manifests/core.json +65 -0
  22. package/resources/claude-customizations/pack-manifests/csharp-legacy.json +11 -0
  23. package/resources/claude-customizations/pack-manifests/csharp-modern.json +12 -0
  24. package/resources/claude-customizations/pack-manifests/powershell.json +14 -0
  25. package/resources/claude-customizations/pack-manifests/python.json +14 -0
  26. package/resources/claude-customizations/pack-manifests/typescript.json +9 -0
  27. package/resources/codex-and-agents-customizations/.agents/skills/feature-promotion-lifecycle/SKILL.md +30 -13
  28. package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +113 -10
  29. package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-workflow/SKILL.md +33 -8
  30. package/resources/codex-and-agents-customizations/.agents/skills/repo-automation-adapter/SKILL.md +12 -7
  31. package/resources/codex-and-agents-customizations/.codex/config.toml +65 -6
  32. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-checkpoint-monotonic.ps1 +303 -0
  33. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-completion-consistency.ps1 +300 -0
  34. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +148 -0
  35. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-pr-author-skill.ps1 +336 -0
  36. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-promotion-mcp-only.ps1 +83 -17
  37. package/resources/config/orchestration-routing.json +2 -10
  38. package/resources/customizations/.github/agents/pr-author.agent.md +25 -0
  39. package/resources/customizations/.github/agents/task-researcher.agent.md +4 -4
  40. package/resources/customizations/.github/prompts/fillout-prd-feature.prompt.md +1 -1
  41. package/resources/customizations/.github/prompts/research-issue.prompt.md +4 -3
  42. package/resources/scripts/dev_tools/push_down_claude_customizations.py +253 -224
  43. package/resources/scripts/dev_tools/push_down_claude_filesystem.py +472 -0
  44. package/resources/scripts/dev_tools/push_down_claude_pack_selection.py +401 -0
  45. package/resources/scripts/dev_tools/validate_orchestrator_state.py +106 -29
  46. package/resources/scripts/dev_tools/validate_policy_audit_artifact.py +24 -0
  47. package/resources/templates/push_down_claude_customizations.py +128 -304
@@ -0,0 +1,401 @@
1
+ """Pack selection, variant routing, and memory-mode helpers for `.claude` push-down.
2
+
3
+ Purpose:
4
+ Provide the pure logic that the Claude customization push-down entry point
5
+ uses to decide which `.claude`-relative files to publish, which C# variant
6
+ source to read for each canonical destination path, and how to treat
7
+ agent-memory files for the selected memory mode. The entry point
8
+ (`push_down_claude_customizations`) composes these helpers with a filesystem
9
+ wrapper and delegates the actual copy to the shared publisher engine.
10
+
11
+ Responsibilities:
12
+ - Load and validate pack-manifest JSON files via an injected filesystem
13
+ adapter (no direct disk I/O here).
14
+ - Compute the set of `.claude`-relative destination paths to publish for a
15
+ given pack selection, always including `core`.
16
+ - Resolve the source-relative path to read for a destination path given the
17
+ selected C# variant (`modern` reads from `.claude`, `legacy` reads from
18
+ the bundle-only `.claude-variants/csharp-legacy/` subtree).
19
+ - Assert C# mutual exclusion so the four canonical C# destination paths are
20
+ written at most once each and a single run never selects both variants.
21
+
22
+ Usage:
23
+ These functions are stateless and are called by
24
+ `scripts.dev_tools.push_down_claude_customizations`. They never touch the
25
+ real filesystem directly; all reads go through the `PushDownFileSystem`
26
+ adapter supplied by the caller.
27
+
28
+ Invariants / Constraints:
29
+ - All manifest paths are `.claude`-relative POSIX strings.
30
+ - `core` is always part of the published set when any explicit pack
31
+ selection is provided.
32
+ - The four canonical C# destination paths are fixed and shared by the
33
+ modern and legacy packs; only one variant may be selected per run.
34
+
35
+ Side Effects:
36
+ None beyond reads performed through the injected adapter.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ from dataclasses import dataclass
43
+ from typing import TYPE_CHECKING, Literal, cast
44
+
45
+ if TYPE_CHECKING:
46
+ from pathlib import Path
47
+
48
+ from scripts.dev_tools.push_down_copilot_customizations_filesystem import (
49
+ PushDownFileSystem,
50
+ )
51
+
52
+ # Pack name reserved for the always-included non-language customization set.
53
+ CORE_PACK_NAME = "core"
54
+
55
+ # The four canonical `.claude`-relative C# destination paths shared by the
56
+ # modern and legacy C# packs. Exactly one variant's content may land at these
57
+ # destinations in a single push-down run.
58
+ CSHARP_CANONICAL_PATHS: tuple[str, ...] = (
59
+ ".claude/rules/csharp.md",
60
+ ".claude/agents/csharp-typed-engineer.md",
61
+ ".claude/skills/csharp-qa-gate/SKILL.md",
62
+ ".claude/skills/invoke-csharp-engineer/SKILL.md",
63
+ )
64
+
65
+ # Pack names whose paths are the canonical C# toolchain. Selecting either pack
66
+ # contributes the same destination paths; the variant flag chooses the source.
67
+ CSHARP_PACK_NAMES: frozenset[str] = frozenset({"csharp-modern", "csharp-legacy"})
68
+
69
+ # Bundle-only source prefix for the legacy C# variant subtree. A legacy
70
+ # destination path's `.claude/` head is replaced with this prefix to locate the
71
+ # source file that holds the legacy profile content.
72
+ LEGACY_VARIANT_SOURCE_PREFIX = ".claude-variants/csharp-legacy"
73
+
74
+ CSharpVariant = Literal["modern", "legacy"]
75
+ MemoryMode = Literal["overwrite", "merge", "skip"]
76
+
77
+
78
+ class ManifestError(ValueError):
79
+ """Raised when a pack manifest is missing or structurally invalid.
80
+
81
+ Purpose:
82
+ Signal a fail-fast condition when the push-down cannot trust the pack
83
+ manifest inputs (a missing manifest file or a manifest that lacks the
84
+ required keys or uses the wrong types). Raising a specific subclass of
85
+ ``ValueError`` lets callers and tests distinguish manifest problems from
86
+ unrelated value errors.
87
+
88
+ Usage:
89
+ Raised by ``load_pack_manifests`` only; not caught internally.
90
+
91
+ Side Effects:
92
+ None.
93
+ """
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class PackManifest:
98
+ """Describe one loaded pack manifest.
99
+
100
+ Purpose:
101
+ Hold the validated contents of a single pack-manifest JSON file in a
102
+ typed, immutable structure so downstream selection logic does not work
103
+ with raw dictionaries.
104
+
105
+ Usage:
106
+ Produced by ``load_pack_manifests`` and consumed by
107
+ ``compute_published_paths``.
108
+
109
+ Invariants / Constraints:
110
+ - ``name`` and ``label`` are non-empty strings.
111
+ - ``paths`` is a tuple of `.claude`-relative POSIX path strings.
112
+ - ``source_prefix`` is ``None`` for packs that read from `.claude`, or a
113
+ bundle-relative prefix string for variant packs that read elsewhere.
114
+
115
+ Attributes:
116
+ name (str): Stable pack identifier (for example ``"python"``).
117
+ label (str): Human-readable label used by the command UI.
118
+ paths (tuple[str, ...]): The `.claude`-relative destination paths this
119
+ pack contributes.
120
+ source_prefix (str | None): Optional bundle-relative source prefix used
121
+ by variant packs; ``None`` for normal packs.
122
+ """
123
+
124
+ name: str
125
+ label: str
126
+ paths: tuple[str, ...]
127
+ source_prefix: str | None
128
+
129
+
130
+ def load_pack_manifests(
131
+ manifest_dir: Path,
132
+ selected_pack_names: frozenset[str],
133
+ fs: PushDownFileSystem,
134
+ ) -> dict[str, PackManifest]:
135
+ """Load and validate the selected pack manifests from the bundle.
136
+
137
+ Purpose:
138
+ Read each selected manifest JSON through the injected filesystem
139
+ adapter, validate its required structure, and return a mapping from pack
140
+ name to its typed :class:`PackManifest`.
141
+
142
+ Args:
143
+ manifest_dir (Path): Directory holding ``<pack>.json`` manifest files,
144
+ typically ``resources/claude-customizations/pack-manifests``.
145
+ selected_pack_names (frozenset[str]): Pack names to load. ``core`` is
146
+ loaded automatically even when it is not present in this set so the
147
+ always-included contract holds.
148
+ fs (PushDownFileSystem): Filesystem adapter used for existence checks
149
+ and text reads. No direct disk I/O is performed here.
150
+
151
+ Returns:
152
+ dict[str, PackManifest]: Mapping of pack name to its validated manifest,
153
+ including the ``core`` manifest.
154
+
155
+ Raises:
156
+ ManifestError: When a required manifest file is missing, is not valid
157
+ JSON, is not a JSON object, or lacks valid ``name``/``label``/
158
+ ``paths`` values.
159
+
160
+ Side Effects:
161
+ Reads manifest files through the injected adapter.
162
+ """
163
+
164
+ # Always load core in addition to the explicitly selected packs so the
165
+ # always-included contract is satisfied at the loading stage.
166
+ names_to_load = set(selected_pack_names) | {CORE_PACK_NAME}
167
+
168
+ manifests: dict[str, PackManifest] = {}
169
+ # Load each selected manifest deterministically (sorted) so any error order
170
+ # is stable and reproducible across runs.
171
+ for name in sorted(names_to_load):
172
+ manifest_path = manifest_dir / f"{name}.json"
173
+ if not fs.is_file(manifest_path):
174
+ raise ManifestError(
175
+ f"Pack manifest is missing for pack '{name}': {manifest_path}"
176
+ )
177
+ raw_text = fs.read_text(manifest_path)
178
+ manifests[name] = _parse_manifest(name, manifest_path, raw_text)
179
+ return manifests
180
+
181
+
182
+ def _parse_manifest(name: str, manifest_path: Path, raw_text: str) -> PackManifest:
183
+ """Parse and validate one manifest's JSON text into a :class:`PackManifest`.
184
+
185
+ Purpose:
186
+ Centralize the structural validation of a single manifest so the loader
187
+ stays focused on filesystem access and iteration.
188
+
189
+ Args:
190
+ name (str): The pack name expected for this manifest (the file stem).
191
+ manifest_path (Path): The manifest path, used only for error messages.
192
+ raw_text (str): The raw JSON text read from the manifest file.
193
+
194
+ Returns:
195
+ PackManifest: The validated, immutable manifest structure.
196
+
197
+ Raises:
198
+ ManifestError: When the text is not valid JSON, is not an object, or has
199
+ missing or wrongly typed ``name``/``label``/``paths``/
200
+ ``source_prefix`` values.
201
+
202
+ Side Effects:
203
+ None.
204
+ """
205
+
206
+ try:
207
+ loaded: object = json.loads(raw_text)
208
+ except json.JSONDecodeError as error:
209
+ raise ManifestError(
210
+ f"Pack manifest is not valid JSON for pack '{name}': {manifest_path}"
211
+ ) from error
212
+
213
+ if not isinstance(loaded, dict):
214
+ raise ManifestError(
215
+ f"Pack manifest must be a JSON object for pack '{name}': {manifest_path}"
216
+ )
217
+
218
+ # Treat the decoded mapping as untyped JSON values; each leaf is narrowed
219
+ # explicitly below so the constructed PackManifest is fully typed.
220
+ parsed = cast("dict[str, object]", loaded)
221
+ manifest_name = parsed.get("name")
222
+ manifest_label = parsed.get("label")
223
+ manifest_paths = parsed.get("paths")
224
+ source_prefix = parsed.get("source_prefix")
225
+
226
+ # Validate the required string keys; both must be non-empty so the command
227
+ # UI and selection logic always have a usable identifier and label.
228
+ if not isinstance(manifest_name, str) or not manifest_name:
229
+ raise ManifestError(
230
+ f"Pack manifest 'name' must be a non-empty string: {manifest_path}"
231
+ )
232
+ if not isinstance(manifest_label, str) or not manifest_label:
233
+ raise ManifestError(
234
+ f"Pack manifest 'label' must be a non-empty string: {manifest_path}"
235
+ )
236
+
237
+ # Validate the paths array is a list of strings, building a typed list so the
238
+ # PackManifest 'paths' tuple is str-typed for downstream consumers.
239
+ if not isinstance(manifest_paths, list):
240
+ raise ManifestError(
241
+ f"Pack manifest 'paths' must be a list of strings: {manifest_path}"
242
+ )
243
+ raw_paths = cast("list[object]", manifest_paths)
244
+ typed_paths: list[str] = []
245
+ # Narrow each entry to str so a non-string element is rejected explicitly.
246
+ for entry in raw_paths:
247
+ if not isinstance(entry, str):
248
+ raise ManifestError(
249
+ f"Pack manifest 'paths' must be a list of strings: {manifest_path}"
250
+ )
251
+ typed_paths.append(entry)
252
+
253
+ # source_prefix is optional; when present it must be a string so variant
254
+ # source resolution can join it with the destination tail.
255
+ if source_prefix is not None and not isinstance(source_prefix, str):
256
+ raise ManifestError(
257
+ f"Pack manifest 'source_prefix' must be a string when present: "
258
+ f"{manifest_path}"
259
+ )
260
+
261
+ return PackManifest(
262
+ name=manifest_name,
263
+ label=manifest_label,
264
+ paths=tuple(typed_paths),
265
+ source_prefix=source_prefix,
266
+ )
267
+
268
+
269
+ def compute_published_paths(
270
+ selected_pack_names: frozenset[str] | None,
271
+ manifests: dict[str, PackManifest],
272
+ ) -> frozenset[str] | None:
273
+ """Compute the `.claude`-relative destination paths to publish.
274
+
275
+ Purpose:
276
+ Translate a pack selection into the concrete set of destination paths
277
+ the push-down should write, always unioning ``core`` into the result so
278
+ the non-language customization set is never dropped.
279
+
280
+ Args:
281
+ selected_pack_names (frozenset[str] | None): The explicitly selected
282
+ pack names, or ``None``/empty to signal the backward-compatible
283
+ "publish everything" default.
284
+ manifests (dict[str, PackManifest]): Loaded manifests for at least the
285
+ selected packs plus ``core``.
286
+
287
+ Returns:
288
+ frozenset[str] | None: The union of the selected packs' paths plus
289
+ ``core`` paths, or ``None`` when no explicit selection was made (the
290
+ caller then publishes the full tree without manifest filtering).
291
+
292
+ Raises:
293
+ ManifestError: When a selected pack has no loaded manifest.
294
+
295
+ Side Effects:
296
+ None.
297
+ """
298
+
299
+ # An empty or None selection means the caller did not pass --packs, so the
300
+ # backward-compatible default is to publish the full tree (signalled by
301
+ # returning None rather than an empty set).
302
+ if not selected_pack_names:
303
+ return None
304
+
305
+ # Always include core so the non-language baseline is published regardless
306
+ # of whether the caller named it explicitly.
307
+ effective_names = set(selected_pack_names) | {CORE_PACK_NAME}
308
+
309
+ published: set[str] = set()
310
+ # Union every selected pack's destination paths into the published set.
311
+ for name in effective_names:
312
+ manifest = manifests.get(name)
313
+ if manifest is None:
314
+ raise ManifestError(f"No loaded manifest for selected pack '{name}'.")
315
+ published.update(manifest.paths)
316
+ return frozenset(published)
317
+
318
+
319
+ def resolve_variant_source_path(
320
+ destination_relative_path: str,
321
+ csharp_variant: CSharpVariant,
322
+ ) -> str:
323
+ """Resolve the source-relative path to read for a destination path.
324
+
325
+ Purpose:
326
+ Route C# canonical destination paths to the correct source: the modern
327
+ variant reads from `.claude`, while the legacy variant reads from the
328
+ bundle-only `.claude-variants/csharp-legacy/` subtree. Non-C# paths and
329
+ the modern variant always resolve to the destination path unchanged.
330
+
331
+ Args:
332
+ destination_relative_path (str): A `.claude`-relative destination path
333
+ (for example ``.claude/rules/csharp.md``).
334
+ csharp_variant (CSharpVariant): ``"modern"`` to read from `.claude`, or
335
+ ``"legacy"`` to read the legacy variant source.
336
+
337
+ Returns:
338
+ str: The source-relative path to read. For ``modern`` or any non-C#
339
+ path this equals ``destination_relative_path``. For ``legacy`` and a C#
340
+ canonical path it is the path under
341
+ ``.claude-variants/csharp-legacy/`` (the leading ``.claude/`` is
342
+ replaced with the legacy prefix).
343
+
344
+ Raises:
345
+ None.
346
+
347
+ Side Effects:
348
+ None.
349
+ """
350
+
351
+ # Only the four canonical C# destinations are variant-routed; everything
352
+ # else (and the modern variant) reads from its own destination path.
353
+ if (
354
+ csharp_variant == "legacy"
355
+ and destination_relative_path in CSHARP_CANONICAL_PATHS
356
+ ):
357
+ # Replace the leading `.claude/` with the legacy variant prefix so the
358
+ # source read targets the bundle-only legacy profile.
359
+ tail = destination_relative_path[len(".claude/") :]
360
+ return f"{LEGACY_VARIANT_SOURCE_PREFIX}/{tail}"
361
+ return destination_relative_path
362
+
363
+
364
+ def assert_single_csharp_toolchain(
365
+ published_paths: frozenset[str],
366
+ selected_pack_names: frozenset[str],
367
+ ) -> None:
368
+ """Assert the C# selection yields exactly one toolchain at the destination.
369
+
370
+ Purpose:
371
+ Enforce C# mutual exclusion: the four canonical C# destination paths may
372
+ be written at most once each, and a single run must not select both the
373
+ modern and legacy C# packs (which would attempt to write two profiles to
374
+ the same destination paths).
375
+
376
+ Args:
377
+ published_paths (frozenset[str]): The computed destination paths for the
378
+ run. A set already deduplicates, so this is checked for the presence
379
+ of the canonical paths rather than for literal duplicates.
380
+ selected_pack_names (frozenset[str]): The selected pack names, used to
381
+ detect selection of both C# variants in one run.
382
+
383
+ Returns:
384
+ None.
385
+
386
+ Raises:
387
+ ManifestError: When both ``csharp-modern`` and ``csharp-legacy`` are
388
+ selected in the same run.
389
+
390
+ Side Effects:
391
+ None.
392
+ """
393
+
394
+ # Selecting both C# packs would route two different sources to the same four
395
+ # canonical destinations; reject it so only one toolchain is ever written.
396
+ selected_csharp = selected_pack_names & CSHARP_PACK_NAMES
397
+ if len(selected_csharp) > 1:
398
+ raise ManifestError(
399
+ "C# mutual exclusion violated: both modern and legacy C# packs were "
400
+ f"selected ({sorted(selected_csharp)}); select exactly one C# variant."
401
+ )
@@ -13,17 +13,16 @@ Usage:
13
13
  Flow:
14
14
  1. Parse the checkpoint JSON payload.
15
15
  2. Validate required top-level keys and status values.
16
- 3. Validate either the legacy list-based receipts or the additive
17
- ``delegation_receipts.promotion.*`` namespace.
16
+ 3. Validate either the legacy list-based receipts, top-level promotion
17
+ receipts, or the additive ``delegation_receipts.promotion.*``
18
+ namespace.
18
19
 
19
20
  Invariants / Constraints:
20
- - The validator accepts both the legacy list and the additive promotion
21
- namespace forms for ``delegation_receipts``.
21
+ - The validator accepts the legacy list, top-level promotion receipts, and
22
+ the additive promotion namespace forms for receipt evidence.
22
23
  - Unsupported namespace keys are rejected.
23
24
  - The validator returns error strings and never mutates the checkpoint.
24
25
 
25
- Side Effects:
26
- None.
27
26
  """
28
27
 
29
28
  from __future__ import annotations
@@ -96,6 +95,10 @@ PROMOTION_RECEIPT_KEYS = (
96
95
  "issue",
97
96
  "feature_folder",
98
97
  )
98
+ ISSUE_232 = "232"
99
+ ISSUE_232_BRANCH = "feature/harden-orchestrate-skill-232"
100
+ PR_GATE_KEYS = ("pr_number", "pr_url", "head_branch", "head_sha")
101
+ CI_GATE_KEYS = ("conclusion", "head_sha", "verified_at")
99
102
  REMEDIATION_LOOP_KEY = "remediation_loop"
100
103
  REMEDIATION_CYCLES_KEY = "cycles"
101
104
  # Execution statuses that may only be recorded once a cycle's preflight gate has
@@ -173,29 +176,6 @@ def _validate_remediation_cycle(index: int, cycle: dict[str, Any]) -> list[str]:
173
176
 
174
177
 
175
178
  def _validate_remediation_loop(remediation_loop: object) -> list[str]:
176
- """Validate the remediation-loop structure and each of its cycles.
177
-
178
- Purpose:
179
- Apply the additive remediation-cycle invariants only when a checkpoint
180
- carries a `remediation_loop`. When the structure is absent, malformed,
181
- or has no cycles, the function produces no errors so existing
182
- step-based checkpoints validate unchanged.
183
-
184
- Args:
185
- remediation_loop (object): The raw value of the checkpoint's top-level
186
- `remediation_loop` key.
187
-
188
- Returns:
189
- list[str]: Validation errors collected across all cycles; empty when no
190
- cycles are present or the structure carries no cycle objects.
191
-
192
- Raises:
193
- None.
194
-
195
- Side Effects:
196
- None.
197
- """
198
-
199
179
  errors: list[str] = []
200
180
 
201
181
  # A non-object remediation_loop carries no cycles to validate; treat it as
@@ -220,6 +200,100 @@ def _validate_remediation_loop(remediation_loop: object) -> list[str]:
220
200
  return errors
221
201
 
222
202
 
203
+ def _missing_object_keys(value: object, keys: tuple[str, ...]) -> list[str]:
204
+ if not isinstance(value, dict):
205
+ return list(keys)
206
+ value_map = cast("dict[str, object]", value)
207
+ missing: list[str] = []
208
+ for key in keys:
209
+ item = value_map.get(key)
210
+ if item is None or (isinstance(item, str) and not item.strip()):
211
+ missing.append(key)
212
+ return missing
213
+
214
+
215
+ def _validate_completion_pr_gate(state: dict[str, Any]) -> list[str]:
216
+ pr_gate = state.get("pr_gate")
217
+ missing = _missing_object_keys(pr_gate, PR_GATE_KEYS)
218
+ if not isinstance(pr_gate, dict):
219
+ return [
220
+ "Checkpoint completion validation failed: pr_gate must be an object "
221
+ f"with keys: {', '.join(PR_GATE_KEYS)}."
222
+ ]
223
+ errors: list[str] = []
224
+ if missing:
225
+ errors.append(
226
+ "Checkpoint completion validation failed: pr_gate missing required "
227
+ f"fields: {', '.join(missing)}."
228
+ )
229
+ if state.get("issue-num") == ISSUE_232:
230
+ head_branch = cast("dict[str, object]", pr_gate).get("head_branch")
231
+ if head_branch != ISSUE_232_BRANCH:
232
+ errors.append(
233
+ "Checkpoint completion validation failed: pr_gate.head_branch "
234
+ f"must be {ISSUE_232_BRANCH} for Issue #232."
235
+ )
236
+ return errors
237
+
238
+
239
+ def _validate_completion_ci_gate(state: dict[str, Any]) -> list[str]:
240
+ ci_gate = state.get("ci_gate")
241
+ missing = _missing_object_keys(ci_gate, CI_GATE_KEYS)
242
+ if not isinstance(ci_gate, dict):
243
+ return [
244
+ "Checkpoint completion validation failed: ci_gate must be an object "
245
+ f"with keys: {', '.join(CI_GATE_KEYS)}."
246
+ ]
247
+ errors: list[str] = []
248
+ if missing:
249
+ errors.append(
250
+ "Checkpoint completion validation failed: ci_gate missing required "
251
+ f"fields: {', '.join(missing)}."
252
+ )
253
+ ci_map = cast("dict[str, object]", ci_gate)
254
+ if ci_map.get("conclusion") != "success":
255
+ errors.append(
256
+ "Checkpoint completion validation failed: ci_gate.conclusion must be "
257
+ "success."
258
+ )
259
+ pr_gate = state.get("pr_gate")
260
+ if isinstance(pr_gate, dict):
261
+ pr_map = cast("dict[str, object]", pr_gate)
262
+ pr_head_sha = pr_map.get("head_sha")
263
+ else:
264
+ pr_head_sha = None
265
+ if pr_head_sha is not None and ci_map.get("head_sha") != pr_head_sha:
266
+ errors.append(
267
+ "Checkpoint completion validation failed: ci_gate.head_sha must match "
268
+ "pr_gate.head_sha."
269
+ )
270
+ return errors
271
+
272
+
273
+ def _validate_issue_232_promotion_receipts(state: dict[str, Any]) -> list[str]:
274
+ if state.get("issue-num") != ISSUE_232:
275
+ return []
276
+ receipts = state.get("delegation_receipts")
277
+ promotion_source = "promotion_receipts"
278
+ promotion: object = state.get("promotion_receipts")
279
+ if isinstance(receipts, dict):
280
+ receipts_map = cast("dict[str, object]", receipts)
281
+ namespaced_promotion = receipts_map.get(PROMOTION_RECEIPT_NAMESPACE_KEY)
282
+ if namespaced_promotion is not None:
283
+ promotion_source = "delegation_receipts.promotion"
284
+ promotion = namespaced_promotion
285
+ missing = _missing_object_keys(promotion, PROMOTION_RECEIPT_KEYS)
286
+ return (
287
+ [
288
+ "Checkpoint completion validation failed: "
289
+ f"{promotion_source} missing required Issue #232 "
290
+ f"receipt keys: {', '.join(missing)}."
291
+ ]
292
+ if missing
293
+ else []
294
+ )
295
+
296
+
223
297
  def _validate_list_delegation_receipts(receipts: list[object]) -> list[str]:
224
298
  """Validate the legacy list-based delegation receipt payload.
225
299
 
@@ -423,6 +497,9 @@ def validate_orchestrator_state_text(
423
497
  errors.append(
424
498
  "Checkpoint completion validation failed: blocked_reason is not `none`."
425
499
  )
500
+ errors.extend(_validate_completion_pr_gate(state_map))
501
+ errors.extend(_validate_completion_ci_gate(state_map))
502
+ errors.extend(_validate_issue_232_promotion_receipts(state_map))
426
503
  errors.extend(validate_routing_contract(state_map))
427
504
 
428
505
  return errors
@@ -66,6 +66,14 @@ PLACEHOLDER_MARKERS = (
66
66
  "unverified",
67
67
  "missing",
68
68
  )
69
+ TEMPLATE_RESOLVER_TOOL = "resolve_policy_audit_template_asset"
70
+ TEMPLATE_RESOLVER_MISSING_MARKERS = (
71
+ "not exposed",
72
+ "was not exposed",
73
+ "missing resolver exposure",
74
+ "fallback template",
75
+ "fallback-template",
76
+ )
69
77
 
70
78
 
71
79
  def _has_numeric_coverage(value: str) -> bool:
@@ -138,6 +146,17 @@ def _has_placeholder_marker(value: str) -> bool:
138
146
  return any(marker in lowered for marker in PLACEHOLDER_MARKERS)
139
147
 
140
148
 
149
+ def _reports_missing_template_resolver_success(text: str) -> bool:
150
+ """Return True for fallback-template artifacts that still claim success."""
151
+
152
+ lowered = text.lower()
153
+ if TEMPLATE_RESOLVER_TOOL not in lowered:
154
+ return False
155
+ if not any(marker in lowered for marker in TEMPLATE_RESOLVER_MISSING_MARKERS):
156
+ return False
157
+ return re.search(r"\b(pass|ready|readiness)\b", lowered) is not None
158
+
159
+
141
160
  def _extract_policy_audit_coverage_rows(text: str) -> list[dict[str, str]]:
142
161
  """Parse the policy-audit coverage table into named row dictionaries.
143
162
 
@@ -441,6 +460,11 @@ def validate_policy_audit_text(text: str) -> list[str]:
441
460
  errors.append("Policy audit still contains the template instruction block.")
442
461
  if "[Component Name]" in text:
443
462
  errors.append("Policy audit still contains placeholder component text.")
463
+ if _reports_missing_template_resolver_success(text):
464
+ errors.append(
465
+ "Policy audit cannot report PASS or READY when "
466
+ f"{TEMPLATE_RESOLVER_TOOL} is reported as missing or not exposed."
467
+ )
444
468
  for heading in POLICY_AUDIT_REQUIRED_HEADINGS:
445
469
  if heading not in text:
446
470
  errors.append(f"Policy audit missing required heading: {heading}")