@danmoisan/drm-copilot-mcp 1.0.22 → 1.0.24

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 (26) hide show
  1. package/out/mcp-server.js +164 -7
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +27 -8
  4. package/resources/claude-customizations/.claude/agents/parallel-planner.md +44 -10
  5. package/resources/claude-customizations/.claude/lib/bash/compute-cohorts.sh +143 -0
  6. package/resources/claude-customizations/.claude/lib/bash/compute-concurrency-batches.sh +122 -0
  7. package/resources/claude-customizations/.claude/lib/bash/parallel-cohorts.sh +330 -0
  8. package/resources/claude-customizations/.claude/lib/bash/parallel-common.sh +238 -0
  9. package/resources/claude-customizations/.claude/lib/bash/parallel-items-validate.sh +244 -0
  10. package/resources/claude-customizations/.claude/lib/bash/parallel-manifest-validate.sh +187 -0
  11. package/resources/claude-customizations/.claude/lib/bash/parallel-yaml-emit.sh +340 -0
  12. package/resources/claude-customizations/.claude/lib/bash/parallel-yaml-scan.sh +335 -0
  13. package/resources/claude-customizations/.claude/lib/bash/validate-parallel-manifest.sh +134 -0
  14. package/resources/claude-customizations/.claude/rules/shell.md +7 -2
  15. package/resources/claude-customizations/.claude/settings.json +3 -0
  16. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +9 -5
  17. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +24 -13
  18. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +62 -21
  19. package/resources/claude-customizations/.claude-variants/csharp-legacy/rules/csharp.md +4 -4
  20. package/resources/claude-customizations/.claude-variants/csharp-legacy/skills/csharp-qa-gate/SKILL.md +5 -3
  21. package/resources/claude-customizations/config/blast-radius.json +16 -0
  22. package/resources/claude-customizations/config/orchestration-routing.json +355 -0
  23. package/resources/claude-customizations/pack-manifests/core.json +14 -1
  24. package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/csharp/SKILL.md +3 -3
  25. package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/csharp-qa-gate/SKILL.md +5 -3
  26. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
package/out/mcp-server.js CHANGED
@@ -18811,9 +18811,161 @@ var ExcludingFileSystem = class {
18811
18811
  }
18812
18812
  };
18813
18813
 
18814
+ // ../../extensions/drm-copilot/src/lib/push-down/claude-routing-merge.ts
18815
+ var ROUTES_KEY = "routes";
18816
+ var AUTHORITATIVE_ROUTE = "parallel";
18817
+ var RoutingMergeError = class extends Error {
18818
+ /** Destination path whose current content could not be parsed. */
18819
+ path;
18820
+ /**
18821
+ * @param path Destination path that failed to parse.
18822
+ * @param detail Parser detail appended to the message.
18823
+ */
18824
+ constructor(path11, detail) {
18825
+ super(
18826
+ `Destination routing document is not valid JSON and was not written: ${path11} (${detail})`
18827
+ );
18828
+ this.name = "RoutingMergeError";
18829
+ this.path = path11;
18830
+ }
18831
+ };
18832
+ function normalizePosix4(value) {
18833
+ return value.replace(/\\/g, "/").replace(/\/+$/, "");
18834
+ }
18835
+ function relativeToPosix4(path11, root) {
18836
+ const normalizedPath = normalizePosix4(path11);
18837
+ const normalizedRoot = normalizePosix4(root);
18838
+ if (normalizedPath === normalizedRoot) {
18839
+ return "";
18840
+ }
18841
+ const prefix = `${normalizedRoot}/`;
18842
+ return normalizedPath.startsWith(prefix) ? normalizedPath.slice(prefix.length) : null;
18843
+ }
18844
+ function parseRoutingObject(text, path11) {
18845
+ let parsed;
18846
+ try {
18847
+ parsed = JSON.parse(text);
18848
+ } catch (error2) {
18849
+ const detail = error2 instanceof Error ? error2.message : String(error2);
18850
+ throw new RoutingMergeError(path11, detail);
18851
+ }
18852
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
18853
+ throw new RoutingMergeError(path11, "document root is not a JSON object");
18854
+ }
18855
+ return parsed;
18856
+ }
18857
+ function asObject(value) {
18858
+ if (value === void 0 || value === null || typeof value !== "object") {
18859
+ return null;
18860
+ }
18861
+ return Array.isArray(value) ? null : value;
18862
+ }
18863
+ function mergeRoutes(destinationRoutes, sourceRoutes) {
18864
+ const merged = {};
18865
+ if (destinationRoutes !== null) {
18866
+ for (const [name, definition] of Object.entries(destinationRoutes)) {
18867
+ const sourceDefinition = sourceRoutes?.[name];
18868
+ merged[name] = name === AUTHORITATIVE_ROUTE && sourceDefinition !== void 0 ? sourceDefinition : definition;
18869
+ }
18870
+ }
18871
+ if (sourceRoutes !== null) {
18872
+ for (const [name, definition] of Object.entries(sourceRoutes)) {
18873
+ if (!(name in merged)) {
18874
+ merged[name] = definition;
18875
+ }
18876
+ }
18877
+ }
18878
+ return merged;
18879
+ }
18880
+ function mergeRoutingDocuments(destinationText, sourceText, path11) {
18881
+ const destination = parseRoutingObject(destinationText, path11);
18882
+ const source = parseRoutingObject(sourceText, path11);
18883
+ const merged = {};
18884
+ for (const [key, value] of Object.entries(destination)) {
18885
+ merged[key] = key === ROUTES_KEY ? mergeRoutes(asObject(value), asObject(source[ROUTES_KEY])) : value;
18886
+ }
18887
+ for (const [key, value] of Object.entries(source)) {
18888
+ if (key in merged) {
18889
+ continue;
18890
+ }
18891
+ merged[key] = key === ROUTES_KEY ? mergeRoutes(null, asObject(value)) : value;
18892
+ }
18893
+ return `${JSON.stringify(merged, null, 2)}
18894
+ `;
18895
+ }
18896
+ var RoutingMergeFileSystem = class {
18897
+ inner;
18898
+ destinationRoot;
18899
+ mergeRelativePath;
18900
+ /**
18901
+ * @param inner The wrapped adapter performing real I/O.
18902
+ * @param destinationRoot Destination workspace root (POSIX path).
18903
+ * @param mergeRelativePath Destination-relative path to merge.
18904
+ */
18905
+ constructor(inner, destinationRoot, mergeRelativePath) {
18906
+ this.inner = inner;
18907
+ this.destinationRoot = normalizePosix4(destinationRoot);
18908
+ this.mergeRelativePath = mergeRelativePath;
18909
+ }
18910
+ /** @inheritdoc */
18911
+ listFiles(root) {
18912
+ return this.inner.listFiles(root);
18913
+ }
18914
+ /** @inheritdoc */
18915
+ isDir(path11) {
18916
+ return this.inner.isDir(path11);
18917
+ }
18918
+ /** @inheritdoc */
18919
+ isFile(path11) {
18920
+ return this.inner.isFile(path11);
18921
+ }
18922
+ /** @inheritdoc */
18923
+ readTextFile(path11) {
18924
+ return this.inner.readTextFile(path11);
18925
+ }
18926
+ /** @inheritdoc */
18927
+ ensureDir(path11) {
18928
+ this.inner.ensureDir(path11);
18929
+ }
18930
+ /**
18931
+ * Write a file, merging the one configured routing path when it exists.
18932
+ *
18933
+ * @param path Absolute destination POSIX path.
18934
+ * @param content Source content the engine wants to publish.
18935
+ * @throws RoutingMergeError When the destination routing document is not
18936
+ * parseable; the destination bytes are left untouched.
18937
+ */
18938
+ writeTextFile(path11, content) {
18939
+ if (!this.isMergeTarget(path11)) {
18940
+ this.inner.writeTextFile(path11, content);
18941
+ return;
18942
+ }
18943
+ if (!this.inner.isFile(path11)) {
18944
+ this.inner.writeTextFile(path11, content);
18945
+ return;
18946
+ }
18947
+ const merged = mergeRoutingDocuments(
18948
+ this.inner.readTextFile(path11),
18949
+ content,
18950
+ path11
18951
+ );
18952
+ this.inner.writeTextFile(path11, merged);
18953
+ }
18954
+ /**
18955
+ * Return whether a destination path is the configured merge target.
18956
+ *
18957
+ * @param path Absolute destination POSIX path.
18958
+ * @returns True when the path resolves to the merge-target relative path.
18959
+ */
18960
+ isMergeTarget(path11) {
18961
+ return relativeToPosix4(path11, this.destinationRoot) === this.mergeRelativePath;
18962
+ }
18963
+ };
18964
+
18814
18965
  // ../../extensions/drm-copilot/src/lib/push-down/claude-customizations.ts
18815
18966
  var ARTIFACT_DIRECTORY4 = "artifacts/claude-customizations";
18816
- var ROOT_FOLDERS2 = [".claude"];
18967
+ var ROOT_FOLDERS2 = [".claude", "config"];
18968
+ var ROUTING_MERGE_RELATIVE_PATH = "config/orchestration-routing.json";
18817
18969
  var EXCLUDED_RELATIVE_PATHS = [
18818
18970
  ".claude/settings.local.json"
18819
18971
  ];
@@ -18862,8 +19014,13 @@ function pushDownCustomizations4(options) {
18862
19014
  effectiveBundle,
18863
19015
  fs9
18864
19016
  );
18865
- const excludingFs = new ExcludingFileSystem(
19017
+ const mergingFs = new RoutingMergeFileSystem(
18866
19018
  fs9,
19019
+ destinationRoot,
19020
+ ROUTING_MERGE_RELATIVE_PATH
19021
+ );
19022
+ const excludingFs = new ExcludingFileSystem(
19023
+ mergingFs,
18867
19024
  repoRoot,
18868
19025
  EXCLUDED_RELATIVE_PATHS,
18869
19026
  {
@@ -29499,7 +29656,7 @@ function resolveReadinessSignal(fs9, featureDir) {
29499
29656
  }
29500
29657
  return [null, null];
29501
29658
  }
29502
- function relativeToPosix4(root, path11) {
29659
+ function relativeToPosix5(root, path11) {
29503
29660
  const normalizedRoot = toPosixPath2(root).replace(/\/+$/u, "");
29504
29661
  const normalized = toPosixPath2(path11);
29505
29662
  if (normalized.startsWith(`${normalizedRoot}/`)) {
@@ -29537,7 +29694,7 @@ function discoverCanonicalEvidenceFiles(fs9, root, feature) {
29537
29694
  for (const pattern of CANONICAL_GLOBS) {
29538
29695
  for (const candidate of fs9.glob(featureRoot, pattern)) {
29539
29696
  if (fs9.isFile(candidate)) {
29540
- discovered.add(relativeToPosix5(normalizedRoot, candidate));
29697
+ discovered.add(relativeToPosix6(normalizedRoot, candidate));
29541
29698
  }
29542
29699
  }
29543
29700
  }
@@ -29608,7 +29765,7 @@ function parseIntegerStrict(value) {
29608
29765
  }
29609
29766
  return Number.parseInt(value, 10);
29610
29767
  }
29611
- function relativeToPosix5(root, absolute) {
29768
+ function relativeToPosix6(root, absolute) {
29612
29769
  const normalized = toPosixPath2(absolute);
29613
29770
  if (normalized.startsWith(`${root}/`)) {
29614
29771
  return normalized.slice(root.length + 1);
@@ -29805,11 +29962,11 @@ function buildContextFiles(params) {
29805
29962
  const contextFiles = [];
29806
29963
  for (const path11 of candidatePaths) {
29807
29964
  if (fs9.exists(path11)) {
29808
- contextFiles.push(relativeToPosix4(root, path11));
29965
+ contextFiles.push(relativeToPosix5(root, path11));
29809
29966
  }
29810
29967
  }
29811
29968
  if (readinessSource !== null) {
29812
- contextFiles.push(relativeToPosix4(root, readinessSource));
29969
+ contextFiles.push(relativeToPosix5(root, readinessSource));
29813
29970
  }
29814
29971
  const evidenceContextFiles = discoverCanonicalEvidenceFiles(
29815
29972
  fs9,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danmoisan/drm-copilot-mcp",
3
- "version": "1.0.22",
3
+ "version": "1.0.24",
4
4
  "description": "Stdio MCP server exposing drm-copilot repo-automation tools.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -15,6 +15,9 @@ tools:
15
15
  - "Bash(gh *)"
16
16
  - "Bash(poetry run python -c *)"
17
17
  - "Bash(poetry run python -m *)"
18
+ - "Bash(bash .claude/lib/bash/compute-cohorts.sh*)"
19
+ - "Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)"
20
+ - "Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)"
18
21
  - "mcp__drm-copilot__collect_pr_context"
19
22
  - "mcp__drm-copilot__validate_orchestration_artifacts"
20
23
  skills:
@@ -66,16 +69,32 @@ frames the *who* and *when*; the skill documents the *how* in full. The manifest
66
69
  checkpoint schema, and the parallel enums are defined once in
67
70
  `.claude/rules/parallel-orchestration.md` and are consumed here, never redefined.
68
71
 
69
- Two of that procedure's steps are reached through a Python interpreter rather than through a
70
- dedicated command, so the `tools` allowlist grants exactly two invocation prefixes for them.
71
- `scripts/dev_tools/parallel_manifest_contract.py` is an import-only library with no CLI entry point,
72
- so the manifest gate's `validate_parallel_manifest_text` check is invoked as
73
- `poetry run python -c`; the checkpoint-validator CLI fallback the skill names in its
74
- `## Parallel-Level Checkpoint` section is invoked as `poetry run python -m`. Both grants are scoped
72
+ The manifest gate is reached through the destination-runtime bash entry point, which needs no
73
+ Python interpreter and is published by push-down alongside `.claude`, so the `tools` allowlist
74
+ grants one entry per command-line entry point
75
+ `"Bash(bash .claude/lib/bash/compute-cohorts.sh*)"`,
76
+ `"Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)"`, and
77
+ `"Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)"` the last of which covers it:
78
+
79
+ ```bash
80
+ bash .claude/lib/bash/validate-parallel-manifest.sh docs/features/parallel/<slug>/parallel.md
81
+ ```
82
+
83
+ Exit 0 accepts the manifest, exit 1 rejects it with one error per line on stdout, and exit 2 means
84
+ the file is unreadable or uses a YAML construct outside the supported subset. The same entry point's
85
+ `--print-mode` and `--print-max-concurrency` subcommands supply `mode` and `max_concurrency` with
86
+ their documented defaults. `validate_parallel_manifest_text` in
87
+ `scripts/dev_tools/parallel_manifest_contract.py` remains the repository authority and the parity
88
+ reference; it is not invoked on the destination-runtime path. Cohort recoloring and concurrency
89
+ batching use `compute-cohorts.sh` and `compute-concurrency-batches.sh` under the same allowlist
90
+ entry.
91
+
92
+ The two `poetry run` grants remain for the repository-local paths that still need an interpreter:
93
+ the checkpoint-validator CLI fallback the skill names in its `## Parallel-Level Checkpoint` section
94
+ is invoked as `poetry run python -m`, and the drift-detection CLI likewise. Both grants stay scoped
75
95
  to those two invocation forms only — not to `poetry run` as a whole — so `pytest`, `black`, `ruff`,
76
96
  and every other `poetry run` subcommand remain outside the allowlist. The sibling persona
77
- `.claude/agents/parallel-planner.md` records the same rationale for the same class of import-only
78
- upstream library.
97
+ `.claude/agents/parallel-planner.md` records the same destination-runtime posture.
79
98
 
80
99
  ## Startup Protocol
81
100
 
@@ -14,6 +14,9 @@ tools:
14
14
  - "Bash(git *)"
15
15
  - "Bash(gh *)"
16
16
  - "Bash(poetry run *)"
17
+ - "Bash(bash .claude/lib/bash/compute-cohorts.sh*)"
18
+ - "Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)"
19
+ - "Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)"
17
20
  - "mcp__drm-copilot__validate_orchestration_artifacts"
18
21
  skills:
19
22
  - policy-compliance-order
@@ -133,17 +136,48 @@ Do not report completion until:
133
136
 
134
137
  ## Upstream Library Invocation
135
138
 
136
- The `"Bash(poetry run *)"` allowlist entry is retained deliberately, and its justification is
137
- recorded here rather than left implicit. The blast-radius library
138
- (`scripts/dev_tools/compute_blast_radius.py`) and the cohort-computation library
139
- (`scripts/dev_tools/parallel_cohort_computation.py`) landed as import-only Python libraries with
140
- no CLI entry point, matching the repository's `scripts/dev_tools/epic_wave_computation.py`
141
- precedent. Radius derivation, V1-V3 validation, the contention relation, and cohort seeding are
142
- therefore reached through a `poetry run` Python invocation, for example:
139
+ Every upstream library this planner needs is reachable from the published customization payload
140
+ alone, with no Python interpreter and no repository checkout. That is the point of the
141
+ destination-portability work in issue #462: a workspace that received `.claude` and `config` can
142
+ plan a parallel run.
143
+
144
+ **Blast radius — PowerShell port.** Radius derivation, V1-V3 validation, and the contention
145
+ relation come from `.claude/lib/blast-radius/BlastRadius.psm1`:
146
+
147
+ ```powershell
148
+ Import-Module .claude/lib/blast-radius/BlastRadius.psm1 -Force
149
+ ```
150
+
151
+ The facade exports `Get-PlanPaths`, `Get-BlastRadius`, `Get-BlastRadiusFromObservedPaths`,
152
+ `Test-BlastRadius`, and `Test-BlastRadiusConflict`. Its truth table is
153
+ `config/blast-radius.json`, which push-down publishes alongside `.claude`.
154
+
155
+ **Cohort seeding and concurrency batching — bash entry points.** The bash library is granted as
156
+ three entry-point-specific allowlist entries — `"Bash(bash .claude/lib/bash/compute-cohorts.sh*)"`,
157
+ `"Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)"`, and
158
+ `"Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)"` — one per command-line entry point.
159
+ The six sourceable libraries carry no grant because they are never invoked directly. The two
160
+ commands below require the first two of those entries:
143
161
 
144
162
  ```bash
145
- poetry run python -c "from scripts.dev_tools.compute_blast_radius import derive_blast_radius"
163
+ bash .claude/lib/bash/compute-cohorts.sh --keys "<k1> <k2> ..." --edges "<a>:<b> ..."
164
+ bash .claude/lib/bash/compute-concurrency-batches.sh --keys "<k1> ..." --max-concurrency <n>
146
165
  ```
147
166
 
148
- That invocation form requires exactly this allowlist entry. Without it the planner cannot obtain a
149
- declared radius or a cohort partition, and planning cannot reach a ready state.
167
+ **Manifest validation bash entry point.** The
168
+ `"Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)"` allowlist entry covers:
169
+
170
+ ```bash
171
+ bash .claude/lib/bash/validate-parallel-manifest.sh <manifest-path>
172
+ bash .claude/lib/bash/validate-parallel-manifest.sh --print-mode <manifest-path>
173
+ bash .claude/lib/bash/validate-parallel-manifest.sh --print-max-concurrency <manifest-path>
174
+ ```
175
+
176
+ **Python modules are the repository authority, not the runtime path.**
177
+ `scripts/dev_tools/compute_blast_radius.py`, `scripts/dev_tools/parallel_cohort_computation.py`,
178
+ and `scripts/dev_tools/parallel_manifest_contract.py` remain the reference implementations that the
179
+ ported libraries are asserted against by shared fixture corpora. Do not invoke them on the
180
+ destination-runtime path; cite them for their contract.
181
+
182
+ The `"Bash(poetry run *)"` allowlist entry is retained for the repository-local paths that still
183
+ need it — it is not required by any step above.
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env bash
2
+ # compute-cohorts.sh: destination-portable command-line entry point for the
3
+ # parallel surface's cohort computation. It exists so a workspace that received
4
+ # the Claude customization payload can compute cohorts with nothing but bash --
5
+ # no Python, no Poetry, no repository checkout.
6
+ #
7
+ # Usage:
8
+ # bash .claude/lib/bash/compute-cohorts.sh --keys "<k1> <k2> ..." \
9
+ # [--edges "<a>:<b> <a>:<b> ..."]
10
+ #
11
+ # `--edges` is optional; omitting it, or passing an empty string, means the
12
+ # conflict graph has no edges. Item keys and edge endpoints are decimal
13
+ # integers matching `-?(0|[1-9][0-9]*)`; a token with a leading zero is
14
+ # rejected fail-closed with a lexical error, because the Python authority would
15
+ # read such a token differently and a silent disagreement is worse than a
16
+ # refusal.
17
+ #
18
+ # Output contract:
19
+ # stdout compact JSON array of arrays, identical to Python
20
+ # json.dumps(..., separators=(",", ":"))
21
+ # stderr on invalid input, the exact message the Python reference
22
+ # implementation raises
23
+ # exit 0 success
24
+ # exit 1 invalid input (duplicate key, self-loop, unknown endpoint)
25
+ # exit 2 usage error or a token outside the accepted integer lexis
26
+ set -euo pipefail
27
+
28
+ # Resolve this script's own directory so the library sources regardless of cwd.
29
+ CC_SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
30
+ # shellcheck source=.claude/lib/bash/parallel-cohorts.sh
31
+ # shellcheck disable=SC1091
32
+ source "$CC_SCRIPT_DIR/parallel-cohorts.sh"
33
+
34
+ pc_enforce_c_locale
35
+
36
+ cc_usage() {
37
+ # Print the entry point's usage text.
38
+ cat <<'EOF'
39
+ Usage: compute-cohorts.sh --keys "<k1> <k2> ..." [--edges "<a>:<b> ..."]
40
+
41
+ Computes parallel execution cohorts from an undirected conflict graph by
42
+ deterministic greedy graph coloring in Welsh-Powell order.
43
+
44
+ Options:
45
+ --keys Space-separated item keys (required; may be an empty string).
46
+ --edges Space-separated conflict edges as <a>:<b> (optional).
47
+
48
+ Prints a compact JSON array of arrays on stdout. On invalid input, prints the
49
+ reference implementation's exact message on stderr and exits 1.
50
+ EOF
51
+ }
52
+
53
+ cc_require_integer() {
54
+ # Validate one token against the accepted decimal-integer lexis.
55
+ #
56
+ # Args: $1 = the token, $2 = a label naming where the token came from.
57
+ # Exits 2 with a lexical error when the token is outside the lexis.
58
+ local token="$1" label="$2"
59
+ if [[ ! $token =~ ^-?(0|[1-9][0-9]*)$ ]]; then
60
+ printf 'compute-cohorts.sh: %s must be a decimal integer matching -?(0|[1-9][0-9]*); found: %s\n' \
61
+ "$label" "$token" >&2
62
+ exit 2
63
+ fi
64
+ }
65
+
66
+ cc_validate_tokens() {
67
+ # Validate every key token and every edge endpoint.
68
+ #
69
+ # Args: $1 = space-separated keys, $2 = space-separated `a:b` edges.
70
+ local keys="$1" edges="$2" token
71
+ pcoh_split_words "$keys"
72
+ local -a key_tokens=("${PCOH_WORDS[@]}")
73
+ for token in "${key_tokens[@]}"; do
74
+ cc_require_integer "$token" "item key"
75
+ done
76
+
77
+ pcoh_split_words "$edges"
78
+ local -a edge_tokens=("${PCOH_WORDS[@]}")
79
+ # Each edge must be exactly two integer endpoints joined by a single colon;
80
+ # anything else is a malformed edge token rather than a graph error.
81
+ for token in "${edge_tokens[@]}"; do
82
+ if [[ $token != *:* || $token == *:*:* ]]; then
83
+ printf 'compute-cohorts.sh: edge must be <a>:<b>; found: %s\n' "$token" >&2
84
+ exit 2
85
+ fi
86
+ cc_require_integer "${token%%:*}" "edge endpoint"
87
+ cc_require_integer "${token#*:}" "edge endpoint"
88
+ done
89
+ }
90
+
91
+ cc_main() {
92
+ # Parse arguments, compute the cohorts, and print the result.
93
+ local keys="" edges="" keys_seen=0
94
+ while (($# > 0)); do
95
+ case "$1" in
96
+ --keys)
97
+ (($# >= 2)) || {
98
+ cc_usage >&2
99
+ return 2
100
+ }
101
+ keys="$2"
102
+ keys_seen=1
103
+ shift 2
104
+ ;;
105
+ --edges)
106
+ (($# >= 2)) || {
107
+ cc_usage >&2
108
+ return 2
109
+ }
110
+ edges="$2"
111
+ shift 2
112
+ ;;
113
+ --help | -h)
114
+ cc_usage
115
+ return 0
116
+ ;;
117
+ *)
118
+ cc_usage >&2
119
+ return 2
120
+ ;;
121
+ esac
122
+ done
123
+ ((keys_seen == 1)) || {
124
+ cc_usage >&2
125
+ return 2
126
+ }
127
+
128
+ cc_validate_tokens "$keys" "$edges"
129
+ if ! pcoh_compute_cohorts "$keys" "$edges"; then
130
+ printf '%s\n' "$PCOH_ERROR" >&2
131
+ return 1
132
+ fi
133
+ printf '%s\n' "$PCOH_RESULT"
134
+ return 0
135
+ }
136
+
137
+ # Guard so the file can be sourced without executing main. main's return code
138
+ # is captured and re-exited explicitly as the final statement.
139
+ if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then
140
+ cc_rc=0
141
+ cc_main "$@" || cc_rc=$?
142
+ exit "$cc_rc"
143
+ fi
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env bash
2
+ # compute-concurrency-batches.sh: destination-portable command-line entry point
3
+ # for the parallel surface's concurrency batching. It exists so a workspace that
4
+ # received the Claude customization payload can cap fan-out with nothing but
5
+ # bash -- no Python, no Poetry, no repository checkout.
6
+ #
7
+ # Usage:
8
+ # bash .claude/lib/bash/compute-concurrency-batches.sh \
9
+ # --keys "<k1> <k2> ..." --max-concurrency <n>
10
+ #
11
+ # Item keys are decimal integers matching `-?(0|[1-9][0-9]*)`; a token with a
12
+ # leading zero is rejected fail-closed with a lexical error, because the Python
13
+ # authority would read such a token differently and a silent disagreement is
14
+ # worse than a refusal.
15
+ #
16
+ # Output contract:
17
+ # stdout compact JSON array of arrays, identical to Python
18
+ # json.dumps(..., separators=(",", ":"))
19
+ # stderr on invalid input, the exact message the Python reference
20
+ # implementation raises
21
+ # exit 0 success
22
+ # exit 1 invalid input (max_concurrency below 1)
23
+ # exit 2 usage error or a token outside the accepted integer lexis
24
+ set -euo pipefail
25
+
26
+ # Resolve this script's own directory so the library sources regardless of cwd.
27
+ CB_SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
28
+ # shellcheck source=.claude/lib/bash/parallel-cohorts.sh
29
+ # shellcheck disable=SC1091
30
+ source "$CB_SCRIPT_DIR/parallel-cohorts.sh"
31
+
32
+ pc_enforce_c_locale
33
+
34
+ cb_usage() {
35
+ # Print the entry point's usage text.
36
+ cat <<'EOF'
37
+ Usage: compute-concurrency-batches.sh --keys "<k1> <k2> ..." --max-concurrency <n>
38
+
39
+ Chunks one cohort's item keys into concurrency-capped batches in ascending key
40
+ order. Every batch holds exactly <n> keys except a possibly smaller final batch.
41
+
42
+ Options:
43
+ --keys Space-separated cohort item keys (required; may be empty).
44
+ --max-concurrency The fan-out cap (required).
45
+
46
+ Prints a compact JSON array of arrays on stdout. When the cap is below 1, prints
47
+ the reference implementation's exact message on stderr and exits 1.
48
+ EOF
49
+ }
50
+
51
+ cb_require_integer() {
52
+ # Validate one token against the accepted decimal-integer lexis.
53
+ #
54
+ # Args: $1 = the token, $2 = a label naming where the token came from.
55
+ # Exits 2 with a lexical error when the token is outside the lexis.
56
+ local token="$1" label="$2"
57
+ if [[ ! $token =~ ^-?(0|[1-9][0-9]*)$ ]]; then
58
+ printf 'compute-concurrency-batches.sh: %s must be a decimal integer matching -?(0|[1-9][0-9]*); found: %s\n' \
59
+ "$label" "$token" >&2
60
+ exit 2
61
+ fi
62
+ }
63
+
64
+ cb_main() {
65
+ # Parse arguments, compute the batches, and print the result.
66
+ local keys="" cap="" keys_seen=0 token
67
+ while (($# > 0)); do
68
+ case "$1" in
69
+ --keys)
70
+ (($# >= 2)) || {
71
+ cb_usage >&2
72
+ return 2
73
+ }
74
+ keys="$2"
75
+ keys_seen=1
76
+ shift 2
77
+ ;;
78
+ --max-concurrency)
79
+ (($# >= 2)) || {
80
+ cb_usage >&2
81
+ return 2
82
+ }
83
+ cap="$2"
84
+ shift 2
85
+ ;;
86
+ --help | -h)
87
+ cb_usage
88
+ return 0
89
+ ;;
90
+ *)
91
+ cb_usage >&2
92
+ return 2
93
+ ;;
94
+ esac
95
+ done
96
+ if ((keys_seen == 0)) || [[ -z $cap ]]; then
97
+ cb_usage >&2
98
+ return 2
99
+ fi
100
+
101
+ cb_require_integer "$cap" "max_concurrency"
102
+ pcoh_split_words "$keys"
103
+ local -a key_tokens=("${PCOH_WORDS[@]}")
104
+ for token in "${key_tokens[@]}"; do
105
+ cb_require_integer "$token" "item key"
106
+ done
107
+
108
+ if ! pcoh_compute_concurrency_batches "$keys" "$cap"; then
109
+ printf '%s\n' "$PCOH_ERROR" >&2
110
+ return 1
111
+ fi
112
+ printf '%s\n' "$PCOH_RESULT"
113
+ return 0
114
+ }
115
+
116
+ # Guard so the file can be sourced without executing main. main's return code
117
+ # is captured and re-exited explicitly as the final statement.
118
+ if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then
119
+ cb_rc=0
120
+ cb_main "$@" || cb_rc=$?
121
+ exit "$cb_rc"
122
+ fi