@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.
- package/out/mcp-server.js +93 -20
- package/package.json +1 -1
- package/resources/claude-customizations/.claude/agents/orchestrator.md +6 -2
- package/resources/claude-customizations/.claude/agents/pr-author.md +78 -0
- package/resources/claude-customizations/.claude/agents/task-researcher.md +10 -6
- package/resources/claude-customizations/.claude/hooks/enforce-checkpoint-monotonic.ps1 +68 -10
- package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +28 -1
- package/resources/claude-customizations/.claude/hooks/enforce-evidence-locations.ps1 +40 -14
- package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +210 -0
- package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +153 -10
- package/resources/claude-customizations/.claude/hooks/validate-pr-author-output.ps1 +136 -0
- package/resources/claude-customizations/.claude/hooks/validate-task-researcher-output.ps1 +22 -6
- package/resources/claude-customizations/.claude/settings.json +22 -0
- package/resources/claude-customizations/.claude/skills/evidence-and-timestamp-conventions/SKILL.md +0 -1
- package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +76 -2
- package/resources/claude-customizations/.claude/skills/research-issue/SKILL.md +5 -3
- package/resources/claude-customizations/.claude-variants/csharp-legacy/agents/csharp-typed-engineer.md +69 -0
- package/resources/claude-customizations/.claude-variants/csharp-legacy/rules/csharp.md +96 -0
- package/resources/claude-customizations/.claude-variants/csharp-legacy/skills/csharp-qa-gate/SKILL.md +77 -0
- package/resources/claude-customizations/.claude-variants/csharp-legacy/skills/invoke-csharp-engineer/SKILL.md +64 -0
- package/resources/claude-customizations/pack-manifests/core.json +65 -0
- package/resources/claude-customizations/pack-manifests/csharp-legacy.json +11 -0
- package/resources/claude-customizations/pack-manifests/csharp-modern.json +12 -0
- package/resources/claude-customizations/pack-manifests/powershell.json +14 -0
- package/resources/claude-customizations/pack-manifests/python.json +14 -0
- package/resources/claude-customizations/pack-manifests/typescript.json +9 -0
- package/resources/codex-and-agents-customizations/.agents/skills/feature-promotion-lifecycle/SKILL.md +30 -13
- package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +113 -10
- package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-workflow/SKILL.md +33 -8
- package/resources/codex-and-agents-customizations/.agents/skills/repo-automation-adapter/SKILL.md +12 -7
- package/resources/codex-and-agents-customizations/.codex/config.toml +65 -6
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-checkpoint-monotonic.ps1 +303 -0
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-completion-consistency.ps1 +300 -0
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +148 -0
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-pr-author-skill.ps1 +336 -0
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-promotion-mcp-only.ps1 +83 -17
- package/resources/config/orchestration-routing.json +2 -10
- package/resources/customizations/.github/agents/pr-author.agent.md +25 -0
- package/resources/customizations/.github/agents/task-researcher.agent.md +4 -4
- package/resources/customizations/.github/prompts/fillout-prd-feature.prompt.md +1 -1
- package/resources/customizations/.github/prompts/research-issue.prompt.md +4 -3
- package/resources/scripts/dev_tools/push_down_claude_customizations.py +253 -224
- package/resources/scripts/dev_tools/push_down_claude_filesystem.py +472 -0
- package/resources/scripts/dev_tools/push_down_claude_pack_selection.py +401 -0
- package/resources/scripts/dev_tools/validate_orchestrator_state.py +106 -29
- package/resources/scripts/dev_tools/validate_policy_audit_artifact.py +24 -0
- 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
|
|
17
|
-
``delegation_receipts.promotion.*``
|
|
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
|
|
21
|
-
namespace forms for
|
|
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}")
|