@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
@@ -18,31 +18,60 @@ Purpose:
18
18
  from __future__ import annotations
19
19
 
20
20
  import argparse
21
- import re
22
21
  from pathlib import Path
23
22
 
24
- AGENT_MEMORY_RELATIVE_ROOT = Path(".claude/agent-memory")
25
- GENERAL_MEMORY_SCOPE = "general"
26
- REPO_MEMORY_SCOPE = "repo"
23
+ try:
24
+ from scripts.dev_tools.push_down_claude_filesystem import (
25
+ AGENT_MEMORY_RELATIVE_ROOT,
26
+ GENERAL_MEMORY_SCOPE,
27
+ REPO_MEMORY_SCOPE,
28
+ ExcludingFileSystem,
29
+ is_general_memory_file,
30
+ read_memory_scope,
31
+ )
32
+ from scripts.dev_tools.push_down_claude_pack_selection import (
33
+ CSharpVariant,
34
+ ManifestError,
35
+ MemoryMode,
36
+ PackManifest,
37
+ assert_single_csharp_toolchain,
38
+ compute_published_paths,
39
+ load_pack_manifests,
40
+ )
41
+ except ModuleNotFoundError as error: # pragma: no cover - bundled import fallback
42
+ if error.name is None or not error.name.startswith("scripts"):
43
+ raise
44
+ from dev_tools.push_down_claude_filesystem import (
45
+ AGENT_MEMORY_RELATIVE_ROOT,
46
+ GENERAL_MEMORY_SCOPE,
47
+ REPO_MEMORY_SCOPE,
48
+ ExcludingFileSystem,
49
+ is_general_memory_file,
50
+ read_memory_scope,
51
+ )
52
+ from dev_tools.push_down_claude_pack_selection import (
53
+ CSharpVariant,
54
+ ManifestError,
55
+ MemoryMode,
56
+ PackManifest,
57
+ assert_single_csharp_toolchain,
58
+ compute_published_paths,
59
+ load_pack_manifests,
60
+ )
27
61
 
28
- # Match a leading YAML frontmatter block: the first `---` line, the block body,
29
- # and the closing `---` line. DOTALL lets the body span multiple lines.
30
- _FRONTMATTER_PATTERN = re.compile(
31
- r"\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)", re.DOTALL
32
- )
33
- # Match a `metadata:` mapping key at column zero, then capture the indented
34
- # block lines that belong to it (lines that are more-indented or blank) until
35
- # the next column-zero key or end of the frontmatter body.
36
- _METADATA_BLOCK_PATTERN = re.compile(
37
- r"^metadata:[ \t]*\r?\n((?:[ \t]+.*(?:\r?\n|\Z)|\r?\n)*)",
38
- re.MULTILINE,
39
- )
40
- # Match a `scope:` leaf inside the metadata block, capturing its scalar value
41
- # up to an optional inline comment. Surrounding quotes are stripped later.
42
- _SCOPE_LEAF_PATTERN = re.compile(
43
- r"^[ \t]+scope:[ \t]*([^\r\n#]*)",
44
- re.MULTILINE,
62
+ # Repo-relative location of the bundle root that holds the pack manifests and
63
+ # the legacy variant subtree. In the repository CLI the source root is the repo
64
+ # root, so the manifests and variant live under this bundle path. The bundled
65
+ # template overrides ``bundle_root`` because its source root already is the
66
+ # bundle's ``claude-customizations`` directory.
67
+ BUNDLE_ROOT_RELATIVE_DIR = Path(
68
+ "extensions/drm-copilot/resources/claude-customizations"
45
69
  )
70
+ # Subdirectory under the bundle root that contains the pack-manifest JSON files.
71
+ PACK_MANIFEST_SUBDIR = "pack-manifests"
72
+ # Valid CLI choices for the C# variant and memory-mode arguments.
73
+ CSHARP_VARIANT_CHOICES: tuple[str, ...] = ("modern", "legacy")
74
+ MEMORY_MODE_CHOICES: tuple[str, ...] = ("overwrite", "merge", "skip")
46
75
 
47
76
  try:
48
77
  from scripts.dev_tools.push_down_copilot_customizations import (
@@ -75,8 +104,13 @@ EXCLUDED_RELATIVE_PATHS: tuple[Path, ...] = (Path(".claude/settings.local.json")
75
104
  __all__ = [
76
105
  "AGENT_MEMORY_RELATIVE_ROOT",
77
106
  "ARTIFACT_DIRECTORY",
107
+ "CSHARP_VARIANT_CHOICES",
78
108
  "EXCLUDED_RELATIVE_PATHS",
79
109
  "GENERAL_MEMORY_SCOPE",
110
+ "BUNDLE_ROOT_RELATIVE_DIR",
111
+ "MEMORY_MODE_CHOICES",
112
+ "PACK_MANIFEST_SUBDIR",
113
+ "ManifestError",
80
114
  "PushDownSummary",
81
115
  "REPO_MEMORY_SCOPE",
82
116
  "ROOT_FOLDERS",
@@ -85,221 +119,70 @@ __all__ = [
85
119
  "push_down_customizations",
86
120
  ]
87
121
 
122
+ # Backward-compatible private aliases. The memory-scope helpers moved to
123
+ # ``push_down_claude_filesystem``; these aliases keep the prior import location
124
+ # stable for existing callers and tests that referenced the private names here.
125
+ _read_memory_scope = read_memory_scope
126
+ _is_general_memory_file = is_general_memory_file
88
127
 
89
- def _read_memory_scope(content: str) -> str:
90
- """Return the declared memory scope from a file's YAML frontmatter.
91
-
92
- Purpose:
93
- Extract the `metadata.scope` leaf from the leading YAML frontmatter
94
- block using a narrow `re`-based parser. This avoids adding a runtime
95
- YAML dependency (PyYAML) while reading only the single leaf the
96
- push-down scope filter requires.
97
-
98
- Args:
99
- content (str): The full text of a candidate memory file, including any
100
- leading `---` frontmatter block.
101
128
 
102
- Returns:
103
- str: ``"general"`` only when the frontmatter contains a `metadata:`
104
- mapping whose `scope:` leaf is exactly ``general`` (quotes and inline
105
- comments stripped). In every other case missing frontmatter, no
106
- closing `---`, no `metadata:` block, no `scope:` leaf, or any value
107
- other than exactly ``general`` — it returns ``"repo"`` as the fail-safe
108
- default so nothing leaks by accident.
129
+ def _passthrough_rewrite(
130
+ text: str,
131
+ ) -> tuple[str, int, int, list[str]]:
132
+ """Return unmodified text for payloads that do not need command rewrites."""
109
133
 
110
- Raises:
111
- None.
134
+ return text, 0, 0, []
112
135
 
113
- Side Effects:
114
- None.
115
- """
116
136
 
117
- # Isolate the leading frontmatter block; absent or unterminated frontmatter
118
- # fails safe to the repo scope so unmarked files are never distributed.
119
- frontmatter_match = _FRONTMATTER_PATTERN.match(content)
120
- if frontmatter_match is None:
121
- return REPO_MEMORY_SCOPE
122
- frontmatter_body = frontmatter_match.group(1)
123
-
124
- # Locate the metadata mapping; without it there is no scope leaf to read.
125
- metadata_match = _METADATA_BLOCK_PATTERN.search(frontmatter_body)
126
- if metadata_match is None:
127
- return REPO_MEMORY_SCOPE
128
- metadata_block = metadata_match.group(1)
129
-
130
- # Read the scope leaf from within the metadata block only; a top-level
131
- # `scope:` outside `metadata:` is intentionally ignored.
132
- scope_match = _SCOPE_LEAF_PATTERN.search(metadata_block)
133
- if scope_match is None:
134
- return REPO_MEMORY_SCOPE
135
-
136
- # Strip surrounding whitespace and optional matching quotes before the
137
- # exact-match comparison; only an exact `general` is treated as general.
138
- scope_value = scope_match.group(1).strip()
139
- if (
140
- len(scope_value) >= 2
141
- and scope_value[0] == scope_value[-1]
142
- and scope_value[0] in {'"', "'"}
143
- ):
144
- scope_value = scope_value[1:-1].strip()
145
- if scope_value == GENERAL_MEMORY_SCOPE:
146
- return GENERAL_MEMORY_SCOPE
147
- return REPO_MEMORY_SCOPE
148
-
149
-
150
- def _is_general_memory_file(relative_path: Path, content: str) -> bool:
151
- """Return whether a candidate file may be distributed by push-down.
137
+ def _resolve_published_paths(
138
+ *,
139
+ packs: frozenset[str] | None,
140
+ bundle_root: Path,
141
+ fs: PushDownFileSystem,
142
+ ) -> frozenset[str] | None:
143
+ """Compute the published `.claude`-relative path set for a pack selection.
152
144
 
153
145
  Purpose:
154
- Decide inclusion for one source file. Files under
155
- `.claude/agent-memory/` are distributed only when general-scoped;
156
- every other file is always distributed and is unaffected by the scope
157
- filter.
146
+ Load the selected pack manifests from the bundle, compute the union of
147
+ their destination paths (always including ``core``), and assert C#
148
+ mutual exclusion. Returns ``None`` when no pack selection was supplied so
149
+ the publisher falls back to the backward-compatible publish-everything
150
+ path with no manifest read.
158
151
 
159
152
  Args:
160
- relative_path (Path): The file path relative to the repository root.
161
- content (str): The full text of the file, used to read the memory
162
- scope when the path is under the agent-memory subtree.
153
+ packs (frozenset[str] | None): Selected pack names, or ``None``/empty for
154
+ the publish-everything default.
155
+ bundle_root (Path): Bundle root that contains the ``pack-manifests``
156
+ subdirectory and the legacy variant subtree.
157
+ fs (PushDownFileSystem): Adapter used to read the manifest files.
163
158
 
164
159
  Returns:
165
- bool: ``True`` when the path is outside `.claude/agent-memory/`, or when
166
- the path is under that subtree and `_read_memory_scope(content)` is
167
- exactly ``general``. ``False`` only for an agent-memory file whose
168
- scope is not general (the fail-safe exclusion).
160
+ frozenset[str] | None: The union of selected packs' paths plus ``core``,
161
+ or ``None`` to signal the publish-everything default.
169
162
 
170
163
  Raises:
171
- None.
164
+ ManifestError: When a manifest is missing/malformed or both C# variants
165
+ are selected in the same run.
172
166
 
173
167
  Side Effects:
174
- None.
168
+ Reads manifest files through the adapter when a selection is present.
175
169
  """
176
170
 
177
- # Files outside the agent-memory subtree are always copied; the scope
178
- # filter never applies to rules, skills, agents, hooks, or settings.
179
- try:
180
- relative_path.relative_to(AGENT_MEMORY_RELATIVE_ROOT)
181
- except ValueError:
182
- return True
183
- return _read_memory_scope(content) == GENERAL_MEMORY_SCOPE
171
+ # No explicit selection means the backward-compatible default: publish the
172
+ # full tree without reading any manifest.
173
+ if not packs:
174
+ return None
184
175
 
185
-
186
- class _ExcludingFileSystem:
187
- """Wrap a PushDownFileSystem and filter specified paths from list_files.
188
-
189
- Purpose:
190
- Prevent host-specific files (e.g. `settings.local.json`) from being
191
- included when the publisher enumerates source files, and exclude
192
- repository-specific agent memories so only general-scoped memories are
193
- distributed. Chosen over a post-enumeration filter so the exclusion
194
- travels with the filesystem contract and is transparent to the shared
195
- engine.
196
-
197
- Usage:
198
- Instantiate with the inner adapter and exclusion paths relative to
199
- the repo root; pass the result as the `fs` argument to the engine.
200
-
201
- Invariants / Constraints:
202
- Excluded paths are resolved once at construction time for O(1) checks.
203
- The content-based scope filter reads file content only for candidates
204
- under `.claude/agent-memory/`; all other files skip the read.
205
-
206
- Side Effects:
207
- Delegates all I/O to the inner adapter.
208
- """
209
-
210
- def __init__(
211
- self, inner: PushDownFileSystem, repo_root: Path, excluded: tuple[Path, ...]
212
- ) -> None:
213
- """Set up the adapter; resolve exclusion paths relative to repo_root."""
214
- self._inner = inner
215
- # Retain the resolved repo root so per-file scope checks can derive the
216
- # repo-relative path needed by the agent-memory scope filter.
217
- self._repo_root = repo_root.resolve()
218
- # Resolve once at init so list_files per-path checks are O(1).
219
- self._excluded: frozenset[Path] = frozenset(
220
- (repo_root / p).resolve() for p in excluded
221
- )
222
-
223
- def _is_scope_included(self, path: Path) -> bool:
224
- """Return whether a candidate file passes the agent-memory scope filter.
225
-
226
- Purpose:
227
- Apply the general-vs-repo memory scope decision to one enumerated
228
- file, reading its content only when the path is under the
229
- agent-memory subtree.
230
-
231
- Args:
232
- path (Path): The absolute candidate path returned by the inner
233
- adapter's ``list_files``.
234
-
235
- Returns:
236
- bool: ``True`` when the file is outside `.claude/agent-memory/` or
237
- is a general-scoped memory; ``False`` for a non-general memory.
238
-
239
- Raises:
240
- None.
241
-
242
- Side Effects:
243
- Reads file content via the inner adapter for agent-memory
244
- candidates only.
245
- """
246
-
247
- # Derive the repo-relative path so the agent-memory check matches the
248
- # `.claude/agent-memory/` prefix regardless of the absolute location.
249
- try:
250
- relative_path = path.resolve().relative_to(self._repo_root)
251
- except ValueError:
252
- # A path outside the repo root cannot be an agent memory; include it.
253
- return True
254
-
255
- # Skip the content read entirely for files outside the memory subtree.
256
- try:
257
- relative_path.relative_to(AGENT_MEMORY_RELATIVE_ROOT)
258
- except ValueError:
259
- return True
260
-
261
- content = self._inner.read_text(path)
262
- return _is_general_memory_file(relative_path, content)
263
-
264
- def list_files(self, root: Path) -> list[Path]:
265
- """Return inner list_files output with excluded paths removed.
266
-
267
- Drops paths in ``EXCLUDED_RELATIVE_PATHS`` and any agent-memory file
268
- that is not general-scoped per the content-based scope filter.
269
- """
270
- return [
271
- p
272
- for p in self._inner.list_files(root)
273
- if p.resolve() not in self._excluded and self._is_scope_included(p)
274
- ]
275
-
276
- def is_dir(self, path: Path) -> bool:
277
- """Delegate to inner adapter."""
278
- return self._inner.is_dir(path)
279
-
280
- def is_file(self, path: Path) -> bool:
281
- """Delegate to inner adapter."""
282
- return self._inner.is_file(path)
283
-
284
- def read_text(self, path: Path) -> str:
285
- """Delegate to inner adapter."""
286
- return self._inner.read_text(path)
287
-
288
- def write_text(self, path: Path, content: str) -> None:
289
- """Delegate to inner adapter."""
290
- self._inner.write_text(path, content)
291
-
292
- def ensure_dir(self, path: Path) -> None:
293
- """Delegate to inner adapter."""
294
- self._inner.ensure_dir(path)
295
-
296
-
297
- def _passthrough_rewrite(
298
- text: str,
299
- ) -> tuple[str, int, int, list[str]]:
300
- """Return unmodified text for payloads that do not need command rewrites."""
301
-
302
- return text, 0, 0, []
176
+ manifest_dir = bundle_root / PACK_MANIFEST_SUBDIR
177
+ manifests: dict[str, PackManifest] = load_pack_manifests(manifest_dir, packs, fs)
178
+ published = compute_published_paths(packs, manifests)
179
+ # compute_published_paths returns None only for an empty selection, which the
180
+ # early return above already excluded; treat a None here as an empty set so
181
+ # the C# exclusion check still runs on a concrete value.
182
+ empty: frozenset[str] = frozenset()
183
+ effective_published = published if published is not None else empty
184
+ assert_single_csharp_toolchain(effective_published, packs)
185
+ return effective_published
303
186
 
304
187
 
305
188
  def push_down_customizations(
@@ -309,15 +192,83 @@ def push_down_customizations(
309
192
  fs: PushDownFileSystem,
310
193
  source_root: Path | None = None,
311
194
  artifact_root: Path | None = None,
195
+ packs: frozenset[str] | None = None,
196
+ csharp_variant: CSharpVariant = "modern",
197
+ memory_mode: MemoryMode = "overwrite",
198
+ bundle_root: Path | None = None,
312
199
  ) -> PushDownSummary:
313
200
  """Copy the `.claude` tree into the destination workspace.
314
201
 
315
- Excludes paths in `EXCLUDED_RELATIVE_PATHS` by wrapping `fs` in
316
- `_ExcludingFileSystem` before delegating to the shared engine.
202
+ Purpose:
203
+ Publish the bundled `.claude` customizations into a destination
204
+ workspace, optionally filtered to a set of language packs, optionally
205
+ sourcing the C# toolchain from the legacy variant, and applying the
206
+ selected agent-memory mode.
207
+
208
+ Args:
209
+ repo_root (Path): Source repository root.
210
+ destination_root (Path): Destination workspace root.
211
+ fs (PushDownFileSystem): Filesystem adapter.
212
+ source_root (Path | None): Explicit source root for packaged content;
213
+ defaults to ``repo_root``.
214
+ artifact_root (Path | None): Explicit artifact root; defaults to
215
+ ``repo_root``.
216
+ packs (frozenset[str] | None): Selected pack names. ``None``/empty
217
+ publishes the full tree (backward-compatible default) with no
218
+ manifest read and no variant routing.
219
+ csharp_variant (CSharpVariant): ``"modern"`` (default) reads C# files
220
+ from `.claude`; ``"legacy"`` reads them from the bundle-only legacy
221
+ variant subtree while still writing the canonical destination paths.
222
+ memory_mode (MemoryMode): ``overwrite`` (default), ``merge``, or
223
+ ``skip`` for agent-memory handling.
224
+ bundle_root (Path | None): Root that holds the ``pack-manifests``
225
+ subdirectory and the legacy variant subtree. Defaults to
226
+ ``source_root / BUNDLE_ROOT_RELATIVE_DIR`` (the repository CLI
227
+ layout). The bundled template passes its ``claude-customizations``
228
+ directory directly because its source root already is that bundle.
229
+
230
+ Returns:
231
+ PushDownSummary: The shared engine's run summary.
232
+
233
+ Raises:
234
+ ManifestError: When a selected manifest is missing/malformed or both C#
235
+ variants are selected.
236
+
237
+ Side Effects:
238
+ Reads source files and writes destination files and the summary artifact
239
+ through the adapter.
317
240
  """
318
241
 
319
- # Wrap the caller-supplied adapter so enumeration omits excluded paths.
320
- excluding_fs = _ExcludingFileSystem(fs, repo_root, EXCLUDED_RELATIVE_PATHS)
242
+ effective_source = source_root if source_root is not None else repo_root
243
+ # Resolve the bundle root that holds manifests and the variant subtree. The
244
+ # repository CLI nests the bundle under the source root; the template passes
245
+ # its own bundle directory directly.
246
+ effective_bundle = (
247
+ bundle_root
248
+ if bundle_root is not None
249
+ else effective_source / BUNDLE_ROOT_RELATIVE_DIR
250
+ )
251
+ # Resolve the published-path set only when a pack selection is supplied so
252
+ # the no-argument path performs no manifest I/O and stays byte-equivalent.
253
+ published_paths = _resolve_published_paths(
254
+ packs=packs,
255
+ bundle_root=effective_bundle,
256
+ fs=fs,
257
+ )
258
+
259
+ # Wrap the caller-supplied adapter so enumeration omits excluded paths and
260
+ # honors the pack, variant, and memory-mode selections.
261
+ excluding_fs = ExcludingFileSystem(
262
+ fs,
263
+ repo_root,
264
+ EXCLUDED_RELATIVE_PATHS,
265
+ source_root=effective_source,
266
+ destination_root=destination_root,
267
+ published_paths=published_paths,
268
+ csharp_variant=csharp_variant,
269
+ memory_mode=memory_mode,
270
+ variant_root=effective_bundle,
271
+ )
321
272
  return push_down_scoped_customizations(
322
273
  repo_root=repo_root,
323
274
  destination_root=destination_root,
@@ -331,7 +282,27 @@ def push_down_customizations(
331
282
 
332
283
 
333
284
  def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
334
- """Parse CLI arguments for the Claude customization push-down publisher."""
285
+ """Parse CLI arguments for the Claude customization push-down publisher.
286
+
287
+ Purpose:
288
+ Define the CLI contract, including the optional pack selection, C#
289
+ variant, and memory-mode flags. When the optional flags are omitted the
290
+ parsed namespace yields the backward-compatible defaults
291
+ (``packs=None``, ``csharp_variant="modern"``, ``memory_mode="overwrite"``).
292
+
293
+ Args:
294
+ argv (list[str] | None): Optional argument list for tests or embedding.
295
+
296
+ Returns:
297
+ argparse.Namespace: Parsed arguments with attributes ``destination``,
298
+ ``packs``, ``csharp_variant``, and ``memory_mode``.
299
+
300
+ Raises:
301
+ SystemExit: Raised by ``argparse`` when arguments are invalid.
302
+
303
+ Side Effects:
304
+ Emits usage/help text through ``argparse`` when parsing fails.
305
+ """
335
306
 
336
307
  parser = argparse.ArgumentParser(
337
308
  description=(
@@ -344,27 +315,85 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
344
315
  required=True,
345
316
  help=("Destination workspace root that will receive the copied .claude tree."),
346
317
  )
318
+ parser.add_argument(
319
+ "--packs",
320
+ default=None,
321
+ help=(
322
+ "Comma-separated language pack names to publish (for example "
323
+ "'core,typescript'). When omitted, the full tree is published. "
324
+ "'core' is always included."
325
+ ),
326
+ )
327
+ parser.add_argument(
328
+ "--csharp-variant",
329
+ dest="csharp_variant",
330
+ choices=CSHARP_VARIANT_CHOICES,
331
+ default="modern",
332
+ help="C# toolchain variant to source ('modern' default or 'legacy').",
333
+ )
334
+ parser.add_argument(
335
+ "--memory-mode",
336
+ dest="memory_mode",
337
+ choices=MEMORY_MODE_CHOICES,
338
+ default="overwrite",
339
+ help=(
340
+ "Agent-memory handling mode: 'overwrite' (default), 'merge', or " "'skip'."
341
+ ),
342
+ )
347
343
  return parser.parse_args(argv)
348
344
 
349
345
 
346
+ def _parse_packs_argument(packs_value: str | None) -> frozenset[str] | None:
347
+ """Parse the raw ``--packs`` CLI value into a normalized pack-name set.
348
+
349
+ Args:
350
+ packs_value (str | None): The raw comma-separated value, or ``None`` when
351
+ the flag was omitted.
352
+
353
+ Returns:
354
+ frozenset[str] | None: The set of non-empty, stripped pack names, or
355
+ ``None`` when the flag was omitted or contained only empty entries (the
356
+ publish-everything default).
357
+ """
358
+
359
+ if packs_value is None:
360
+ return None
361
+ # Strip whitespace and drop empty entries so trailing commas or stray spaces
362
+ # do not produce empty pack names.
363
+ names = {entry.strip() for entry in packs_value.split(",") if entry.strip()}
364
+ if not names:
365
+ return None
366
+ return frozenset(names)
367
+
368
+
350
369
  def main(
351
370
  argv: list[str] | None = None,
352
371
  *,
353
372
  repo_root: Path | None = None,
354
373
  fs: PushDownFileSystem | None = None,
355
374
  ) -> int:
356
- """Run the Claude customization push-down publisher CLI."""
375
+ """Run the Claude customization push-down publisher CLI.
376
+
377
+ Parses arguments, resolves defaults, threads the pack selection, C# variant,
378
+ and memory mode into ``push_down_customizations``, and prints the summary
379
+ artifact path. With no optional flags this is byte-for-byte equivalent to
380
+ the prior publish-everything/overwrite behavior.
381
+ """
357
382
 
358
383
  args = parse_args(argv)
359
384
  resolved_repo_root = resolve_cli_path(repo_root or Path.cwd())
360
385
  resolved_destination = resolve_cli_path(args.destination)
361
386
  resolved_fs = fs or RealPushDownFileSystem()
387
+ packs = _parse_packs_argument(args.packs)
362
388
  summary = push_down_customizations(
363
389
  repo_root=resolved_repo_root,
364
390
  destination_root=resolved_destination,
365
391
  fs=resolved_fs,
366
392
  source_root=resolved_repo_root,
367
393
  artifact_root=resolved_repo_root,
394
+ packs=packs,
395
+ csharp_variant=args.csharp_variant,
396
+ memory_mode=args.memory_mode,
368
397
  )
369
398
  print(f"Wrote push-down summary artifact to: {summary.artifact_path}")
370
399
  return 0